subx_cli/core/formats/
converter.rs

1//! Subtitle format conversion engine.
2//!
3//! This module provides the `FormatConverter`, which performs
4//! format conversions between different subtitle formats,
5//! supporting concurrent processing and task coordination.
6//!
7//! # Examples
8//!
9//! ```rust,ignore
10//! use subx_cli::core::formats::converter::FormatConverter;
11//! // Initialize with default configuration and run conversion tasks
12//! let converter = FormatConverter::new(Default::default());
13//! ```
14
15use futures::future::join_all;
16use std::path::Path;
17use std::sync::Arc;
18use tokio::sync::Semaphore;
19
20use crate::Result;
21use crate::core::formats::Subtitle;
22use crate::core::formats::manager::FormatManager;
23
24/// Subtitle format converter for handling conversion tasks.
25///
26/// The `FormatConverter` coordinates conversion requests across
27/// multiple subtitle formats, managing concurrency and task scheduling.
28pub struct FormatConverter {
29    format_manager: FormatManager,
30    pub(crate) config: ConversionConfig,
31}
32
33impl Clone for FormatConverter {
34    fn clone(&self) -> Self {
35        FormatConverter::new(self.config.clone())
36    }
37}
38
39/// Conversion configuration
40#[derive(Debug, Clone)]
41pub struct ConversionConfig {
42    /// Whether to preserve styling information during conversion
43    pub preserve_styling: bool,
44    /// Target character encoding for the output file
45    pub target_encoding: String,
46    /// Whether to keep the original file after conversion
47    pub keep_original: bool,
48    /// Whether to validate the output after conversion
49    pub validate_output: bool,
50}
51
52/// Result of a subtitle format conversion operation.
53///
54/// Contains detailed information about the conversion process including
55/// success status, format information, entry counts, and any issues encountered.
56#[derive(Debug)]
57pub struct ConversionResult {
58    /// Whether the conversion completed successfully
59    pub success: bool,
60    /// Input subtitle format (e.g., "srt", "ass")
61    pub input_format: String,
62    /// Output subtitle format (e.g., "srt", "ass")
63    pub output_format: String,
64    /// Number of subtitle entries in the original file
65    pub original_entries: usize,
66    /// Number of subtitle entries successfully converted
67    pub converted_entries: usize,
68    /// Non-fatal warnings encountered during conversion
69    pub warnings: Vec<String>,
70    /// Errors encountered during conversion
71    pub errors: Vec<String>,
72}
73
74impl FormatConverter {
75    /// Create new converter
76    pub fn new(config: ConversionConfig) -> Self {
77        Self {
78            format_manager: FormatManager::new(),
79            config,
80        }
81    }
82
83    /// Convert single file
84    pub async fn convert_file(
85        &self,
86        input_path: &Path,
87        output_path: &Path,
88        target_format: &str,
89    ) -> crate::Result<ConversionResult> {
90        // 1. Read and parse input file
91        let input_content = self.read_file_with_encoding(input_path).await?;
92        let input_subtitle = self.format_manager.parse_auto(&input_content)?;
93
94        // 2. Execute format conversion
95        let converted_subtitle = self.transform_subtitle(input_subtitle.clone(), target_format)?;
96
97        // 3. Serialize to target format
98        let target_formatter = self
99            .format_manager
100            .get_format(target_format)
101            .ok_or_else(|| {
102                crate::error::SubXError::subtitle_format(
103                    format!("Unsupported target format: {}", target_format),
104                    "",
105                )
106            })?;
107
108        let output_content = target_formatter.serialize(&converted_subtitle)?;
109
110        // 4. Write file
111        self.write_file_with_encoding(output_path, &output_content)
112            .await?;
113
114        // 5. Validate conversion result
115        let result = if self.config.validate_output {
116            self.validate_conversion(&input_subtitle, &converted_subtitle)
117                .await?
118        } else {
119            ConversionResult {
120                success: true,
121                input_format: input_subtitle.format.to_string(),
122                output_format: target_format.to_string(),
123                original_entries: input_subtitle.entries.len(),
124                converted_entries: converted_subtitle.entries.len(),
125                warnings: Vec::new(),
126                errors: Vec::new(),
127            }
128        };
129        Ok(result)
130    }
131
132    /// Batch convert files
133    pub async fn convert_batch(
134        &self,
135        input_dir: &Path,
136        target_format: &str,
137        recursive: bool,
138    ) -> crate::Result<Vec<ConversionResult>> {
139        let subtitle_files = self.discover_subtitle_files(input_dir, recursive).await?;
140        let semaphore = Arc::new(Semaphore::new(4));
141
142        let tasks = subtitle_files.into_iter().map(|file_path| {
143            let sem = semaphore.clone();
144            let converter = self.clone();
145            let format = target_format.to_string();
146            async move {
147                let _permit = sem.acquire().await.unwrap();
148                let output_path = file_path.with_extension(&format);
149                converter
150                    .convert_file(&file_path, &output_path, &format)
151                    .await
152            }
153        });
154
155        let results = join_all(tasks).await;
156        results.into_iter().collect::<Result<Vec<_>>>()
157    }
158    /// Discover subtitle files in directory
159    async fn discover_subtitle_files(
160        &self,
161        input_dir: &Path,
162        recursive: bool,
163    ) -> crate::Result<Vec<std::path::PathBuf>> {
164        let discovery = crate::core::matcher::discovery::FileDiscovery::new();
165        let media_files = discovery.scan_directory(input_dir, recursive)?;
166        let paths = media_files
167            .into_iter()
168            .filter(|f| {
169                matches!(
170                    f.file_type,
171                    crate::core::matcher::discovery::MediaFileType::Subtitle
172                )
173            })
174            .map(|f| f.path) // Use path field, behavior unchanged
175            .collect();
176        Ok(paths)
177    }
178
179    /// Read file and convert to UTF-8 string
180    async fn read_file_with_encoding(&self, path: &Path) -> crate::Result<String> {
181        let bytes = tokio::fs::read(path).await?;
182        // Auto-detect encoding and convert to UTF-8
183        let detector = crate::core::formats::encoding::EncodingDetector::with_defaults();
184        let info = detector.detect_encoding(&bytes)?;
185        let converter = crate::core::formats::encoding::EncodingConverter::new();
186        let conversion = converter.convert_to_utf8(&bytes, &info.charset)?;
187        Ok(conversion.converted_text)
188    }
189
190    /// Write file (temporarily using UTF-8 encoding)
191    async fn write_file_with_encoding(&self, path: &Path, content: &str) -> crate::Result<()> {
192        tokio::fs::write(path, content).await?;
193        Ok(())
194    }
195
196    /// Simple conversion quality validation
197    async fn validate_conversion(
198        &self,
199        original: &Subtitle,
200        converted: &Subtitle,
201    ) -> crate::Result<ConversionResult> {
202        let success = original.entries.len() == converted.entries.len();
203        let errors = if success {
204            Vec::new()
205        } else {
206            vec![format!(
207                "Entry count mismatch: {} -> {}",
208                original.entries.len(),
209                converted.entries.len()
210            )]
211        };
212        Ok(ConversionResult {
213            success,
214            input_format: original.format.to_string(),
215            output_format: converted.format.to_string(),
216            original_entries: original.entries.len(),
217            converted_entries: converted.entries.len(),
218            warnings: Vec::new(),
219            errors,
220        })
221    }
222}