subx_cli/commands/
match_command.rs1use crate::Result;
2use crate::cli::MatchArgs;
3use crate::cli::display_match_results;
4use crate::config::init_config_manager;
5use crate::config::load_config;
6use crate::core::matcher::{FileDiscovery, MatchConfig, MatchEngine, MediaFileType};
7use crate::core::parallel::{
8 FileProcessingTask, ProcessingOperation, Task, TaskResult, TaskScheduler,
9};
10use crate::services::ai::{AIClientFactory, AIProvider};
11use indicatif::ProgressDrawTarget;
12
13pub async fn execute(args: MatchArgs) -> Result<()> {
15 let config = load_config()?;
17 let ai_client = AIClientFactory::create_client(&config.ai)?;
19 execute_with_client(args, ai_client).await
20}
21
22pub async fn execute_with_client(args: MatchArgs, ai_client: Box<dyn AIProvider>) -> Result<()> {
24 let config = load_config()?;
26 let match_config = MatchConfig {
27 confidence_threshold: args.confidence as f32 / 100.0,
28 max_sample_length: config.ai.max_sample_length,
29 enable_content_analysis: true,
31 backup_enabled: args.backup || config.general.backup_enabled,
32 };
33 let engine = MatchEngine::new(ai_client, match_config);
34
35 let operations = engine.match_files(&args.path, args.recursive).await?;
37
38 display_match_results(&operations, args.dry_run);
40
41 if args.dry_run {
42 engine
43 .save_cache(&args.path, args.recursive, &operations)
44 .await?;
45 } else {
46 engine.execute_operations(&operations, args.dry_run).await?;
48 }
49 Ok(())
50}
51
52pub async fn execute_parallel_match(
54 directory: &std::path::Path,
55 recursive: bool,
56 output: Option<&std::path::Path>,
57) -> Result<()> {
58 init_config_manager()?;
59 let scheduler = TaskScheduler::new()?;
60 let discovery = FileDiscovery::new();
61 let files = discovery.scan_directory(directory, recursive)?;
62 let mut tasks: Vec<Box<dyn Task + Send + Sync>> = Vec::new();
63 for f in files
64 .iter()
65 .filter(|f| matches!(f.file_type, MediaFileType::Video))
66 {
67 let task = Box::new(FileProcessingTask {
68 input_path: f.path.clone(),
69 output_path: output.map(|p| p.to_path_buf()),
70 operation: ProcessingOperation::MatchFiles { recursive },
71 });
72 tasks.push(task);
73 }
74 if tasks.is_empty() {
75 println!("未找到需要處理的影片檔案");
76 return Ok(());
77 }
78 println!("準備並行處理 {} 個檔案", tasks.len());
79 println!("最大並行數: {}", scheduler.get_active_workers());
80 let progress_bar = {
81 let pb = create_progress_bar(tasks.len());
82 if let Ok(cfg) = load_config() {
84 if !cfg.general.enable_progress_bar {
85 pb.set_draw_target(ProgressDrawTarget::hidden());
86 }
87 }
88 pb
89 };
90 let results = monitor_batch_execution(&scheduler, tasks, &progress_bar).await?;
91 let (mut ok, mut failed, mut partial) = (0, 0, 0);
92 for r in &results {
93 match r {
94 TaskResult::Success(_) => ok += 1,
95 TaskResult::Failed(_) | TaskResult::Cancelled => failed += 1,
96 TaskResult::PartialSuccess(_, _) => partial += 1,
97 }
98 }
99 println!("\n處理完成統計:");
100 println!(" ✓ 成功: {} 個檔案", ok);
101 if partial > 0 {
102 println!(" ⚠ 部分成功: {} 個檔案", partial);
103 }
104 if failed > 0 {
105 println!(" ✗ 失敗: {} 個檔案", failed);
106 for (i, r) in results.iter().enumerate() {
107 if matches!(r, TaskResult::Failed(_)) {
108 println!(" 失敗詳情 {}: {}", i + 1, r);
109 }
110 }
111 }
112 Ok(())
113}
114
115async fn monitor_batch_execution(
116 scheduler: &TaskScheduler,
117 tasks: Vec<Box<dyn Task + Send + Sync>>,
118 progress_bar: &indicatif::ProgressBar,
119) -> Result<Vec<TaskResult>> {
120 use tokio::time::{Duration, interval};
121 let handles: Vec<_> = tasks
122 .into_iter()
123 .map(|t| {
124 let s = scheduler.clone();
125 tokio::spawn(async move { s.submit_task(t).await })
126 })
127 .collect();
128 let mut ticker = interval(Duration::from_millis(500));
129 let mut completed = 0;
130 let total = handles.len();
131 let mut results = Vec::new();
132 for mut h in handles {
133 loop {
134 tokio::select! {
135 res = &mut h => {
136 match res {
137 Ok(Ok(r)) => results.push(r),
138 Ok(Err(_)) => results.push(TaskResult::Failed("任務執行錯誤".into())),
139 Err(_) => results.push(TaskResult::Cancelled),
140 }
141 completed += 1;
142 progress_bar.set_position(completed);
143 break;
144 }
145 _ = ticker.tick() => {
146 let active = scheduler.list_active_tasks().len();
147 let queued = scheduler.get_queue_size();
148 progress_bar.set_message(format!("執行中: {} | 佇列: {} | 已完成: {}/{}", active, queued, completed, total));
149 }
150 }
151 }
152 }
153 progress_bar.finish_with_message("所有任務已完成");
154 Ok(results)
155}
156
157fn create_progress_bar(total: usize) -> indicatif::ProgressBar {
158 use indicatif::ProgressStyle;
159 let pb = indicatif::ProgressBar::new(total as u64);
160 pb.set_style(
161 ProgressStyle::default_bar()
162 .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
163 .unwrap()
164 .progress_chars("#>-"),
165 );
166 pb
167}
168
169#[cfg(test)]
170mod tests {
171 use super::execute_with_client;
172 use crate::cli::MatchArgs;
173 use crate::config::init_config_manager;
174 use crate::services::ai::{
175 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
176 };
177 use async_trait::async_trait;
178 use std::fs;
179 use std::path::PathBuf;
180 use tempfile::tempdir;
181
182 struct DummyAI;
183 #[async_trait]
184 impl AIProvider for DummyAI {
185 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
186 Ok(MatchResult {
187 matches: Vec::new(),
188 confidence: 0.0,
189 reasoning: String::new(),
190 })
191 }
192 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
193 panic!("verify_match should not be called in dry-run test");
194 }
195 }
196
197 #[tokio::test]
199 async fn dry_run_creates_cache_and_skips_execute_operations() -> crate::Result<()> {
200 let media_dir = tempdir()?;
202 let media_path = media_dir.path().join("media");
203 fs::create_dir_all(&media_path)?;
204 let video = media_path.join("video.mkv");
205 let subtitle = media_path.join("subtitle.ass");
206 fs::write(&video, b"dummy")?;
207 fs::write(&subtitle, b"dummy")?;
208
209 unsafe {
211 std::env::set_var("XDG_CONFIG_HOME", media_dir.path());
212 }
213 init_config_manager()?;
215
216 let cache_path = dirs::config_dir()
218 .unwrap()
219 .join("subx")
220 .join("match_cache.json");
221 assert!(!cache_path.exists(), "測試開始時不應存在快取檔案");
222
223 let args = MatchArgs {
225 path: PathBuf::from(&media_path),
226 dry_run: true,
227 recursive: false,
228 confidence: 80,
229 backup: false,
230 };
231 execute_with_client(args, Box::new(DummyAI)).await?;
232
233 assert!(cache_path.exists(), "dry_run 後應建立快取檔案");
235 assert!(
236 video.exists(),
237 "dry_run 不應執行 execute_operations,影片檔仍須存在"
238 );
239 assert!(
240 subtitle.exists(),
241 "dry_run 不應執行 execute_operations,字幕檔仍須存在"
242 );
243 Ok(())
244 }
245}