Compare commits

...

5 commits

Author SHA1 Message Date
CDN
7ddaac6aba
chore: bump version
All checks were successful
Build and Release / Build (darwin-amd64) (push) Successful in 23s
Deploy docs / deploy (push) Successful in 45s
Build and Release / Build (linux-amd64) (push) Successful in 21s
Build and Release / Build (darwin-arm64) (push) Successful in 19s
Build and Release / Build (linux-arm64) (push) Successful in 18s
Build and Release / Build (windows-amd64) (push) Successful in 27s
Build and Release / Build (windows-arm64) (push) Successful in 17s
Build and Release / Create Release (push) Successful in 15s
2025-04-23 19:33:26 +08:00
CDN
6d730fa69b
docs: add ass docs 2025-04-23 19:33:02 +08:00
CDN
76e1298ded
chore: seperate large files 2025-04-23 19:22:41 +08:00
CDN
ebbf516689
feat: basic ass processing (without style) 2025-04-23 17:42:13 +08:00
CDN
8897d7ae90
docs: add notes about unstable behaviors 2025-04-23 16:37:12 +08:00
52 changed files with 6113 additions and 2949 deletions

View file

@ -149,6 +149,91 @@ func TestExecute_UnknownCommand(t *testing.T) {
} }
} }
// TestExecute_SyncCommand tests the sync command through Execute
func TestExecute_SyncCommand(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Create temporary test directory
tempDir := t.TempDir()
// Create source and target files
sourceFile := filepath.Join(tempDir, "source.lrc")
targetFile := filepath.Join(tempDir, "target.lrc")
if err := os.WriteFile(sourceFile, []byte("[00:01.00]Test line"), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
if err := os.WriteFile(targetFile, []byte("[00:10.00]Target line"), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for sync command
os.Args = []string{"sub-cli", "sync", sourceFile, targetFile}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify no error message or expected error format
if strings.Contains(output, "Error:") && !strings.Contains(output, "Error: ") {
t.Errorf("Expected formatted error or no error, got: %s", output)
}
}
// TestExecute_ConvertCommand tests the convert command through Execute
func TestExecute_ConvertCommand(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Create temporary test directory
tempDir := t.TempDir()
// Create source file
sourceContent := `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.`
sourceFile := filepath.Join(tempDir, "source.srt")
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Define target file
targetFile := filepath.Join(tempDir, "target.lrc")
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for convert command
os.Args = []string{"sub-cli", "convert", sourceFile, targetFile}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify target file exists
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Errorf("Target file was not created")
}
}
// TestHandleSync tests the sync command // TestHandleSync tests the sync command
func TestHandleSync(t *testing.T) { func TestHandleSync(t *testing.T) {
// Create temporary test directory // Create temporary test directory
@ -381,3 +466,22 @@ func TestHandleFormat_NoArgs(t *testing.T) {
t.Errorf("Expected fmt usage information when no args provided") t.Errorf("Expected fmt usage information when no args provided")
} }
} }
// TestHandleFormat_Error tests the error path in handleFormat
func TestHandleFormat_Error(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute format command with non-existent file
nonExistentFile := "/non/existent/path.srt"
handleFormat([]string{nonExistentFile})
// Get output
cleanup()
output := outBuf.String()
// Verify error message is printed
if !strings.Contains(output, "Error:") {
t.Errorf("Expected error message for non-existent file, got: %s", output)
}
}

View file

@ -36,9 +36,10 @@ sub-cli convert <source> <target>
| Source Format | Target Format | Notes | | Source Format | Target Format | Notes |
|---------------|---------------|-------| |---------------|---------------|-------|
| SRT (.srt) | SRT, VTT, LRC, TXT | - | | SRT (.srt) | SRT, VTT, LRC, TXT, ASS | - |
| VTT (.vtt) | SRT, VTT, LRC, TXT | - | | VTT (.vtt) | SRT, VTT, LRC, TXT, ASS | - |
| LRC (.lrc) | SRT, VTT, LRC, TXT | - | | LRC (.lrc) | SRT, VTT, LRC, TXT, ASS | - |
| ASS (.ass) | SRT, VTT, LRC, TXT, ASS | - |
| TXT (.txt) | — | TXT can only be a target format, not a source format | | TXT (.txt) | — | TXT can only be a target format, not a source format |
### Feature Preservation ### Feature Preservation
@ -61,6 +62,18 @@ The conversion process aims to preserve as many features as possible, but some f
- For the last entry, a default duration (typically 3-5 seconds) is added to create an end time - For the last entry, a default duration (typically 3-5 seconds) is added to create an end time
- **Lost when converting to LRC**: When other formats are converted to LRC, any end timestamp information is discarded - **Lost when converting to LRC**: When other formats are converted to LRC, any end timestamp information is discarded
#### ASS Features
- **Preserved**: Text content, timeline (start and end times), basic styling information
- **Minimalist approach**: Conversion creates a "minimal" ASS file with essential structure
- **When converting to ASS**:
- Basic styles (bold, italic, underline) are converted to ASS styles with default settings
- Default font is Arial, size 20pt with standard colors and margins
- Only "Dialogue" events are created (not "Comment" or other event types)
- **When converting from ASS**:
- Only "Dialogue" events are converted, "Comment" events are ignored
- Style information is preserved where the target format supports it
- ASS-specific attributes (Layer, MarginL/R/V, etc.) are stored as metadata when possible
#### TXT Features #### TXT Features
- **Output only**: Plain text format contains only the text content without any timing or styling - **Output only**: Plain text format contains only the text content without any timing or styling
@ -83,6 +96,12 @@ sub-cli convert lyrics.lrc transcript.txt
# Convert from WebVTT to SRT # Convert from WebVTT to SRT
sub-cli convert subtitles.vtt subtitles.srt sub-cli convert subtitles.vtt subtitles.srt
# Convert from SRT to ASS
sub-cli convert subtitles.srt subtitles.ass
# Convert from ASS to SRT
sub-cli convert subtitles.ass subtitles.srt
``` ```
## sync ## sync
@ -108,6 +127,7 @@ Currently, synchronization only works between files of the same format:
- SRT to SRT - SRT to SRT
- LRC to LRC - LRC to LRC
- VTT to VTT - VTT to VTT
- ASS to ASS
### Behavior Details ### Behavior Details
@ -117,7 +137,7 @@ Currently, synchronization only works between files of the same format:
- **When entry counts differ**: The source timeline is scaled to match the target content using linear interpolation: - **When entry counts differ**: The source timeline is scaled to match the target content using linear interpolation:
- For each target entry position, a corresponding position in the source timeline is calculated - For each target entry position, a corresponding position in the source timeline is calculated
- Times are linearly interpolated between the nearest source entries - Times are linearly interpolated between the nearest source entries
- This ensures smooth and proportional timing across entries of different counts - This ensures smooth and proportional timing distribution across varying entry counts
- **Preserved from target**: All content text and metadata (artist, title, etc.). - **Preserved from target**: All content text and metadata (artist, title, etc.).
- **Modified in target**: Only timestamps are updated. - **Modified in target**: Only timestamps are updated.
@ -125,21 +145,31 @@ Currently, synchronization only works between files of the same format:
- **When entry counts match**: Both start and end times from the source are directly applied to the target entries. - **When entry counts match**: Both start and end times from the source are directly applied to the target entries.
- **When entry counts differ**: A scaled approach using linear interpolation is used: - **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between the nearest source entries - Start times are calculated using linear interpolation between source entries
- End times are calculated based on source entry durations - End times are calculated based on source entry durations
- The timing relationship between entries is preserved - The time relationships between entries are preserved
- **Preserved from target**: All subtitle text content. - **Preserved from target**: All content text.
- **Modified in target**: Timestamps are updated and entry numbers are standardized (sequential from 1). - **Modified in target**: Timestamps are updated and cue identifiers are standardized (sequential from 1).
#### For VTT Files: #### For VTT Files:
- **When entry counts match**: Both start and end times from the source are directly applied to the target entries. - **When entry counts match**: Both start and end times from the source are directly applied to the target entries.
- **When entry counts differ**: A scaled approach using linear interpolation is used, similar to SRT synchronization: - **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between the nearest source entries - Start times are calculated using linear interpolation between source entries
- End times are calculated based on source entry durations - End times are calculated based on source entry durations
- The timing relationship between entries is preserved - The time relationships between entries are preserved
- **Preserved from target**: All subtitle text content, formatting, cue settings, and styling. - **Preserved from target**: All subtitle text content and styling information.
- **Modified in target**: Timestamps are updated and cue identifiers are standardized (sequential from 1). - **Modified in target**: Timestamps are updated and cue identifiers are standardized.
#### For ASS Files:
- **When entry counts match**: The source timeline (start and end times) is directly applied to the target events.
- **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between source events
- End times are calculated based on source event durations
- The time relationships between events are preserved
- **Preserved from target**: All event text content, style references, and other attributes like Layer, MarginL/R/V.
- **Modified in target**: Only the timestamps (Start and End) are updated.
### Timeline Interpolation Details ### Timeline Interpolation Details
@ -177,6 +207,9 @@ sub-cli sync reference.lrc target.lrc
# Synchronize a VTT file using another VTT file as reference # Synchronize a VTT file using another VTT file as reference
sub-cli sync reference.vtt target.vtt sub-cli sync reference.vtt target.vtt
# Synchronize an ASS file using another ASS file as reference
sub-cli sync reference.ass target.ass
``` ```
## fmt ## fmt
@ -202,6 +235,7 @@ sub-cli fmt <file>
| SRT | `.srt` | Standardizes entry numbering (sequential from 1)<br>Formats timestamps in `00:00:00,000` format<br>Ensures proper spacing between entries | | SRT | `.srt` | Standardizes entry numbering (sequential from 1)<br>Formats timestamps in `00:00:00,000` format<br>Ensures proper spacing between entries |
| LRC | `.lrc` | Organizes metadata tags<br>Standardizes timestamp format `[mm:ss.xx]`<br>Ensures proper content alignment | | LRC | `.lrc` | Organizes metadata tags<br>Standardizes timestamp format `[mm:ss.xx]`<br>Ensures proper content alignment |
| VTT | `.vtt` | Validates WEBVTT header<br>Standardizes cue identifiers<br>Formats timestamps in `00:00:00.000` format<br>Organizes styling information | | VTT | `.vtt` | Validates WEBVTT header<br>Standardizes cue identifiers<br>Formats timestamps in `00:00:00.000` format<br>Organizes styling information |
| ASS | `.ass` | Standardizes section order ([Script Info], [V4+ Styles], [Events])<br>Formats timestamps in `h:mm:ss.cc` format<br>Preserves all script info, styles and event data |
### Format-Specific Details ### Format-Specific Details
@ -214,6 +248,9 @@ For LRC files, the formatter preserves all metadata and content but standardizes
#### VTT Formatting #### VTT Formatting
When formatting WebVTT files, the command ensures proper header format, sequential cue identifiers, and standard timestamp formatting. All VTT-specific features like styling, positioning, and comments are preserved. When formatting WebVTT files, the command ensures proper header format, sequential cue identifiers, and standard timestamp formatting. All VTT-specific features like styling, positioning, and comments are preserved.
#### ASS Formatting
The formatter reads and parses the ASS file, then regenerates it with standardized structure. It maintains all original content, including script information, styles, and events. The standard section order ([Script Info], [V4+ Styles], [Events]) is enforced, and timestamps are formatted in the standard `h:mm:ss.cc` format.
### Examples ### Examples
```bash ```bash
@ -225,6 +262,9 @@ sub-cli fmt lyrics.lrc
# Format a VTT file # Format a VTT file
sub-cli fmt subtitles.vtt sub-cli fmt subtitles.vtt
# Format an ASS file
sub-cli fmt subtitles.ass
``` ```
## version ## version
@ -261,3 +301,4 @@ sub-cli help
# Display help for the convert command # Display help for the convert command
sub-cli help convert sub-cli help convert
```

View file

@ -5,17 +5,23 @@ description: Introduction to the Sub-CLI tool and its capabilities
# Getting Started with Sub-CLI # Getting Started with Sub-CLI
::: info Current Status
We've in the process of building basic features of Sub-CLI. Behavior may be unstable.
:::
Sub-CLI is a command-line tool designed for subtitle manipulation and generation. Whether you need to convert subtitle formats, synchronize timelines, format subtitle files, Sub-CLI provides a robust set of features for all your subtitle needs. Sub-CLI is a command-line tool designed for subtitle manipulation and generation. Whether you need to convert subtitle formats, synchronize timelines, format subtitle files, Sub-CLI provides a robust set of features for all your subtitle needs.
## What Can Sub-CLI Do? ## What Can Sub-CLI Do?
- **Convert** between various subtitle formats (SRT, VTT, LRC, TXT) - **Convert** between various subtitle formats (SRT, VTT, LRC, ASS, TXT)
- **Synchronize** timelines between subtitle files - **Synchronize** timelines between subtitle files
- **Format** subtitle files to ensure consistent styling - **Format** subtitle files to ensure consistent styling
## Key Features ## Key Features
- **Format Flexibility**: Support for multiple subtitle formats including SRT, VTT, LRC, and plain text - **Format Flexibility**: Support for multiple subtitle formats including SRT, VTT, LRC, ASS, and plain text
- **Timeline Synchronization**: Easily align subtitles with audio/video content - **Timeline Synchronization**: Easily align subtitles with audio/video content
- **Format-Specific Feature Preservation**: Maintains format-specific features during conversion - **Format-Specific Feature Preservation**: Maintains format-specific features during conversion
- **Clean Command Interface**: Simple, intuitive commands for efficient workflow - **Clean Command Interface**: Simple, intuitive commands for efficient workflow
@ -42,6 +48,9 @@ sub-cli sync source.srt target.srt
# Format a subtitle file # Format a subtitle file
sub-cli fmt subtitle.srt sub-cli fmt subtitle.srt
# Convert to ASS format
sub-cli convert input.srt output.ass
``` ```
Check out the [Command Examples](/examples) page for more detailed usage scenarios. Check out the [Command Examples](/examples) page for more detailed usage scenarios.

View file

@ -36,9 +36,10 @@ sub-cli convert <源文件> <目标文件>
| 源格式 | 目标格式 | 注意 | | 源格式 | 目标格式 | 注意 |
|---------------|---------------|-------| |---------------|---------------|-------|
| SRT (.srt) | SRT, VTT, LRC, TXT | - | | SRT (.srt) | SRT, VTT, LRC, TXT, ASS | - |
| VTT (.vtt) | SRT, VTT, LRC, TXT | - | | VTT (.vtt) | SRT, VTT, LRC, TXT, ASS | - |
| LRC (.lrc) | SRT, VTT, LRC, TXT | - | | LRC (.lrc) | SRT, VTT, LRC, TXT, ASS | - |
| ASS (.ass) | SRT, VTT, LRC, TXT, ASS | - |
| TXT (.txt) | — | TXT只能作为目标格式不能作为源格式 | | TXT (.txt) | — | TXT只能作为目标格式不能作为源格式 |
### 功能保留 ### 功能保留
@ -61,6 +62,18 @@ sub-cli convert <源文件> <目标文件>
- 对于最后一个条目添加默认时长通常3-5秒来创建结束时间 - 对于最后一个条目添加默认时长通常3-5秒来创建结束时间
- **转换为LRC时丢失**: 当其他格式转换为LRC时任何结束时间戳信息都会被丢弃 - **转换为LRC时丢失**: 当其他格式转换为LRC时任何结束时间戳信息都会被丢弃
#### ASS功能
- **保留**: 文本内容、时间线(开始和结束时间)、基本样式信息
- **仅有基本支持**: 转换创建一个具有基本结构的"最小"ASS文件
- **转换为ASS时**:
- 基本样式粗体、斜体、下划线会转换为具有默认设置的ASS样式
- 默认字体为Arial大小20pt具有标准颜色和边距
- 只创建"Dialogue"(对话)类型的事件(不创建"Comment"或其他事件类型)
- **从ASS转换时**:
- 只转换类型为"Dialogue"的事件,忽略"Comment"事件
- 在目标格式支持的情况下保留样式信息
- ASS特有的属性如Layer、MarginL/R/V等在可能的情况下存储为元数据
#### TXT功能 #### TXT功能
- **仅输出**: 纯文本格式只包含没有任何时间或样式的文本内容 - **仅输出**: 纯文本格式只包含没有任何时间或样式的文本内容
@ -83,6 +96,12 @@ sub-cli convert lyrics.lrc transcript.txt
# 从WebVTT转换为SRT # 从WebVTT转换为SRT
sub-cli convert subtitles.vtt subtitles.srt sub-cli convert subtitles.vtt subtitles.srt
# 从SRT转换为ASS
sub-cli convert subtitles.srt subtitles.ass
# 从ASS转换为SRT
sub-cli convert subtitles.ass subtitles.srt
``` ```
## sync ## sync
@ -108,6 +127,7 @@ sub-cli sync <源文件> <目标文件>
- SRT到SRT - SRT到SRT
- LRC到LRC - LRC到LRC
- VTT到VTT - VTT到VTT
- ASS到ASS
### 行为详情 ### 行为详情
@ -128,19 +148,29 @@ sub-cli sync <源文件> <目标文件>
- 开始时间使用源条目之间的线性插值计算 - 开始时间使用源条目之间的线性插值计算
- 结束时间根据源条目时长计算 - 结束时间根据源条目时长计算
- 保持条目之间的时间关系 - 保持条目之间的时间关系
- **从目标保留**: 所有字幕文本内容。 - **从目标保留**: 所有内容文本
- **在目标中修改**: 更新时间戳并标准化条目编号从1开始顺序编号 - **在目标中修改**: 更新时间戳并标准化条目编号从1开始顺序编号
#### 对于VTT文件 #### 对于VTT文件
- **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。 - **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。
- **当条目数不同时**: 使用基于线性插值的缩放方法类似于SRT同步 - **当条目数不同时**: 使用基于线性插值的缩放方法:
- 开始时间使用源条目之间的线性插值计算 - 开始时间使用源条目之间的线性插值计算
- 结束时间根据源条目时长计算 - 结束时间根据源条目时长计算
- 保持条目之间的时间关系 - 保持条目之间的时间关系
- **从目标保留**: 所有字幕文本内容和样式信息。 - **从目标保留**: 所有字幕文本内容和样式信息。
- **在目标中修改**: 更新时间戳并标准化提示标识符。 - **在目标中修改**: 更新时间戳并标准化提示标识符。
#### 对于ASS文件
- **当条目数匹配时**: 源时间线(开始和结束时间)直接应用于目标事件。
- **当条目数不同时**: 使用基于线性插值的缩放方法:
- 开始时间使用源事件之间的线性插值计算
- 结束时间根据源事件时长计算
- 保持事件之间的时间关系
- **从目标保留**: 所有事件文本内容、样式引用和其他属性如Layer、MarginL/R/V
- **在目标中修改**: 只更新时间戳Start和End
### 时间线插值详情 ### 时间线插值详情
同步命令使用线性插值来处理源文件和目标文件之间不同的条目数量: 同步命令使用线性插值来处理源文件和目标文件之间不同的条目数量:
@ -177,6 +207,9 @@ sub-cli sync reference.lrc target.lrc
# 使用另一个VTT文件作为参考来同步VTT文件 # 使用另一个VTT文件作为参考来同步VTT文件
sub-cli sync reference.vtt target.vtt sub-cli sync reference.vtt target.vtt
# 使用另一个ASS文件作为参考来同步ASS文件
sub-cli sync reference.ass target.ass
``` ```
## fmt ## fmt
@ -202,6 +235,7 @@ sub-cli fmt <文件>
| SRT | `.srt` | 标准化条目编号从1开始顺序<br>格式化时间戳为`00:00:00,000`格式<br>确保条目之间适当的间距 | | SRT | `.srt` | 标准化条目编号从1开始顺序<br>格式化时间戳为`00:00:00,000`格式<br>确保条目之间适当的间距 |
| LRC | `.lrc` | 组织元数据标签<br>标准化时间戳格式`[mm:ss.xx]`<br>确保正确的内容对齐 | | LRC | `.lrc` | 组织元数据标签<br>标准化时间戳格式`[mm:ss.xx]`<br>确保正确的内容对齐 |
| VTT | `.vtt` | 验证WEBVTT头<br>标准化提示标识符<br>格式化时间戳为`00:00:00.000`格式<br>组织样式信息 | | VTT | `.vtt` | 验证WEBVTT头<br>标准化提示标识符<br>格式化时间戳为`00:00:00.000`格式<br>组织样式信息 |
| ASS | `.ass` | 标准化部分顺序([Script Info], [V4+ Styles], [Events]<br>格式化时间戳为`h:mm:ss.cc`格式<br>保留所有脚本信息、样式和事件数据 |
### 格式特定详情 ### 格式特定详情
@ -214,6 +248,9 @@ sub-cli fmt <文件>
#### VTT格式化 #### VTT格式化
格式化WebVTT文件时命令确保适当的头格式、顺序提示标识符和标准时间戳格式。所有VTT特定功能如样式、定位和注释都被保留。 格式化WebVTT文件时命令确保适当的头格式、顺序提示标识符和标准时间戳格式。所有VTT特定功能如样式、定位和注释都被保留。
#### ASS格式化
格式化器读取并解析ASS文件然后以标准化结构重新生成它。它保持所有原始内容包括脚本信息、样式和事件。强制执行标准部分顺序[Script Info], [V4+ Styles], [Events]),并以标准的`h:mm:ss.cc`格式格式化时间戳。
### 示例 ### 示例
```bash ```bash
@ -225,6 +262,9 @@ sub-cli fmt lyrics.lrc
# 格式化VTT文件 # 格式化VTT文件
sub-cli fmt subtitles.vtt sub-cli fmt subtitles.vtt
# 格式化ASS文件
sub-cli fmt subtitles.ass
``` ```
## version ## version

View file

@ -5,17 +5,23 @@ description: Sub-CLI 介绍及其功能
# Sub-CLI 快速开始 # Sub-CLI 快速开始
::: info 当前状态
我们正在构建 Sub-CLI 的基础功能。程序行为可能不稳定。
:::
Sub-CLI 是一款专为字幕处理和生成设计的命令行工具。无论您需要转换字幕格式、同步时间轴还是格式化字幕文件Sub-CLI 都能为您的所有字幕需求提供功能支持。 Sub-CLI 是一款专为字幕处理和生成设计的命令行工具。无论您需要转换字幕格式、同步时间轴还是格式化字幕文件Sub-CLI 都能为您的所有字幕需求提供功能支持。
## Sub-CLI 能做什么? ## Sub-CLI 能做什么?
- **转换**在多种字幕格式之间转换SRT、VTT、LRC、TXT - **转换**在多种字幕格式之间转换SRT、VTT、LRC、ASS、TXT
- **同步**:字幕文件之间的时间轴同步 - **同步**:字幕文件之间的时间轴同步
- **格式化**:确保字幕文件具有一致的样式 - **格式化**:确保字幕文件具有一致的样式
## 主要特点 ## 主要特点
- **格式灵活性**:支持多种字幕格式,包括 SRT、VTT、LRC 和纯文本 - **格式灵活性**:支持多种字幕格式,包括 SRT、VTT、LRC、ASS 和纯文本
- **时间轴同步**:轻松将字幕与音频/视频内容对齐 - **时间轴同步**:轻松将字幕与音频/视频内容对齐
- **格式特定功能保留**:在转换过程中保持格式特定的功能 - **格式特定功能保留**:在转换过程中保持格式特定的功能
- **简洁的命令界面**:简单、直观的命令,提高工作效率 - **简洁的命令界面**:简单、直观的命令,提高工作效率
@ -42,6 +48,9 @@ sub-cli sync source.srt target.srt
# 格式化字幕文件 # 格式化字幕文件
sub-cli fmt subtitle.srt sub-cli fmt subtitle.srt
# 转换为ASS格式
sub-cli convert input.srt output.ass
``` ```
查看[命令示例](/zh-Hans/examples)页面获取更多详细使用场景。 查看[命令示例](/zh-Hans/examples)页面获取更多详细使用场景。

View file

@ -1,7 +1,7 @@
package config package config
// Version stores the current application version // Version stores the current application version
const Version = "0.5.1" const Version = "0.6.0"
// Usage stores the general usage information // Usage stores the general usage information
const Usage = `Usage: sub-cli [command] [options] const Usage = `Usage: sub-cli [command] [options]
@ -17,6 +17,8 @@ const SyncUsage = `Usage: sub-cli sync <source> <target>
Currently supports synchronizing between files of the same format: Currently supports synchronizing between files of the same format:
- LRC to LRC - LRC to LRC
- SRT to SRT - SRT to SRT
- VTT to VTT
- ASS to ASS
If source and target have different numbers of entries, a warning will be shown.` If source and target have different numbers of entries, a warning will be shown.`
// ConvertUsage stores the usage information for the convert command // ConvertUsage stores the usage information for the convert command
@ -26,4 +28,5 @@ const ConvertUsage = `Usage: sub-cli convert <source> <target>
.txt Plain text format (No meta/timeline tags, only support as target format) .txt Plain text format (No meta/timeline tags, only support as target format)
.srt SubRip Subtitle format .srt SubRip Subtitle format
.lrc LRC format .lrc LRC format
.vtt WebVTT format` .vtt WebVTT format
.ass Advanced SubStation Alpha format`

View file

@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sub-cli/internal/format/ass"
"sub-cli/internal/format/lrc" "sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt" "sub-cli/internal/format/srt"
"sub-cli/internal/format/txt" "sub-cli/internal/format/txt"
@ -45,6 +46,8 @@ func convertToIntermediate(sourceFile, sourceFormat string) (model.Subtitle, err
return srt.ConvertToSubtitle(sourceFile) return srt.ConvertToSubtitle(sourceFile)
case "vtt": case "vtt":
return vtt.ConvertToSubtitle(sourceFile) return vtt.ConvertToSubtitle(sourceFile)
case "ass":
return ass.ConvertToSubtitle(sourceFile)
default: default:
return model.Subtitle{}, fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFormat) return model.Subtitle{}, fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFormat)
} }
@ -59,6 +62,8 @@ func convertFromIntermediate(subtitle model.Subtitle, targetFile, targetFormat s
return srt.ConvertFromSubtitle(subtitle, targetFile) return srt.ConvertFromSubtitle(subtitle, targetFile)
case "vtt": case "vtt":
return vtt.ConvertFromSubtitle(subtitle, targetFile) return vtt.ConvertFromSubtitle(subtitle, targetFile)
case "ass":
return ass.ConvertFromSubtitle(subtitle, targetFile)
case "txt": case "txt":
return txt.GenerateFromSubtitle(subtitle, targetFile) return txt.GenerateFromSubtitle(subtitle, targetFile)
default: default:

View file

@ -0,0 +1,186 @@
package ass
import (
"fmt"
"sub-cli/internal/model"
)
// ConvertToSubtitle 将ASS文件转换为通用字幕格式
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
// 解析ASS文件
assFile, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, fmt.Errorf("解析ASS文件失败: %w", err)
}
// 创建通用字幕结构
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
// 转换标题
if title, ok := assFile.ScriptInfo["Title"]; ok {
subtitle.Title = title
}
// 转换事件为字幕条目
for i, event := range assFile.Events {
// 只转换对话类型的事件
if event.Type == "Dialogue" {
entry := model.SubtitleEntry{
Index: i + 1,
StartTime: event.StartTime,
EndTime: event.EndTime,
Text: event.Text,
Styles: make(map[string]string),
Metadata: make(map[string]string),
}
// 记录样式信息
entry.Styles["style"] = event.Style
// 记录ASS特有信息
entry.Metadata["Layer"] = fmt.Sprintf("%d", event.Layer)
entry.Metadata["Name"] = event.Name
entry.Metadata["MarginL"] = fmt.Sprintf("%d", event.MarginL)
entry.Metadata["MarginR"] = fmt.Sprintf("%d", event.MarginR)
entry.Metadata["MarginV"] = fmt.Sprintf("%d", event.MarginV)
entry.Metadata["Effect"] = event.Effect
subtitle.Entries = append(subtitle.Entries, entry)
}
}
return subtitle, nil
}
// ConvertFromSubtitle 将通用字幕格式转换为ASS文件
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
// 创建ASS文件结构
assFile := model.NewASSFile()
// 设置标题
if subtitle.Title != "" {
assFile.ScriptInfo["Title"] = subtitle.Title
}
// 转换字幕条目为ASS事件
for _, entry := range subtitle.Entries {
event := model.NewASSEvent()
event.Type = "Dialogue"
event.StartTime = entry.StartTime
event.EndTime = entry.EndTime
event.Text = entry.Text
// 检查是否有ASS特有的元数据
if layer, ok := entry.Metadata["Layer"]; ok {
fmt.Sscanf(layer, "%d", &event.Layer)
}
if name, ok := entry.Metadata["Name"]; ok {
event.Name = name
}
if marginL, ok := entry.Metadata["MarginL"]; ok {
fmt.Sscanf(marginL, "%d", &event.MarginL)
}
if marginR, ok := entry.Metadata["MarginR"]; ok {
fmt.Sscanf(marginR, "%d", &event.MarginR)
}
if marginV, ok := entry.Metadata["MarginV"]; ok {
fmt.Sscanf(marginV, "%d", &event.MarginV)
}
if effect, ok := entry.Metadata["Effect"]; ok {
event.Effect = effect
}
// 处理样式
if style, ok := entry.Styles["style"]; ok {
event.Style = style
} else {
// 根据基本样式设置ASS样式
if _, ok := entry.Styles["bold"]; ok {
// 创建一个加粗样式(如果尚未存在)
styleName := "Bold"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
boldStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["italic"]; ok {
// 创建一个斜体样式(如果尚未存在)
styleName := "Italic"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
italicStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, italicStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["underline"]; ok {
// 创建一个下划线样式(如果尚未存在)
styleName := "Underline"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
underlineStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Underline,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,1,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, underlineStyle)
}
event.Style = styleName
}
}
assFile.Events = append(assFile.Events, event)
}
// 生成ASS文件
return Generate(assFile, filePath)
}

View file

@ -0,0 +1,210 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create test ASS file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.
Dialogue: 1,0:00:05.00,0:00:08.00,Bold,Character,15,25,35,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "convert_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test conversion to Subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Verify results
if subtitle.Format != "ass" {
t.Errorf("Format should be 'ass', got '%s'", subtitle.Format)
}
if subtitle.Title != "Test ASS File" {
t.Errorf("Title should be 'Test ASS File', got '%s'", subtitle.Title)
}
// Only dialogue events should be converted
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 subtitle entries, got %d", len(subtitle.Entries))
} else {
// Check first entry
if subtitle.Entries[0].Text != "This is the first subtitle line." {
t.Errorf("First entry text mismatch: got '%s'", subtitle.Entries[0].Text)
}
if subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v - %+v",
subtitle.Entries[0].StartTime, subtitle.Entries[0].EndTime)
}
// Check style conversion
if subtitle.Entries[0].Styles["style"] != "Default" {
t.Errorf("First entry style mismatch: got '%s'", subtitle.Entries[0].Styles["style"])
}
// Check metadata conversion
if subtitle.Entries[0].Metadata["Layer"] != "0" {
t.Errorf("First entry layer mismatch: got '%s'", subtitle.Entries[0].Metadata["Layer"])
}
if subtitle.Entries[0].Metadata["Name"] != "Character" {
t.Errorf("First entry name mismatch: got '%s'", subtitle.Entries[0].Metadata["Name"])
}
if subtitle.Entries[0].Metadata["MarginL"] != "10" ||
subtitle.Entries[0].Metadata["MarginR"] != "20" ||
subtitle.Entries[0].Metadata["MarginV"] != "30" {
t.Errorf("First entry margins mismatch: got L=%s, R=%s, V=%s",
subtitle.Entries[0].Metadata["MarginL"],
subtitle.Entries[0].Metadata["MarginR"],
subtitle.Entries[0].Metadata["MarginV"])
}
if subtitle.Entries[0].Metadata["Effect"] != "Fade" {
t.Errorf("First entry effect mismatch: got '%s'", subtitle.Entries[0].Metadata["Effect"])
}
// Check second entry (Bold style)
if subtitle.Entries[1].Styles["style"] != "Bold" {
t.Errorf("Second entry style mismatch: got '%s'", subtitle.Entries[1].Styles["style"])
}
if subtitle.Entries[1].Metadata["Layer"] != "1" {
t.Errorf("Second entry layer mismatch: got '%s'", subtitle.Entries[1].Metadata["Layer"])
}
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
subtitle.Title = "Test Conversion"
// Create entries
entry1 := model.SubtitleEntry{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "This is the first subtitle line.",
Styles: map[string]string{"style": "Default"},
Metadata: map[string]string{
"Layer": "0",
"Name": "Character",
"MarginL": "10",
"MarginR": "20",
"MarginV": "30",
"Effect": "Fade",
},
}
entry2 := model.SubtitleEntry{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "This is the second subtitle line.",
Styles: map[string]string{"bold": "1"},
}
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert back to ASS
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "convert_back.ass")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Read the generated file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
contentStr := string(content)
// Verify file content
if !strings.Contains(contentStr, "Title: Test Conversion") {
t.Errorf("Missing or incorrect title in generated file")
}
// Check that both entries were converted correctly
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.") {
t.Errorf("First entry not converted correctly")
}
// Check that bold style was created and applied
if !strings.Contains(contentStr, "Style: Bold") {
t.Errorf("Bold style not created")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") {
t.Errorf("Second entry not converted with Bold style")
}
// Parse the file again to check structure
assFile, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse the generated file: %v", err)
}
if len(assFile.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(assFile.Events))
}
// Check style conversion
var boldStyleFound bool
for _, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyleFound = true
break
}
}
if !boldStyleFound {
t.Errorf("Bold style not found in generated file")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.ass")
if err == nil {
t.Error("Converting non-existent file should return an error")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Test invalid path
subtitle := model.NewSubtitle()
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Converting to invalid path should return an error")
}
}

View file

@ -0,0 +1,17 @@
package ass
import (
"fmt"
)
// Format 格式化ASS文件
func Format(filePath string) error {
// 读取ASS文件
assFile, err := Parse(filePath)
if err != nil {
return fmt.Errorf("解析ASS文件失败: %w", err)
}
// 写回格式化后的ASS文件
return Generate(assFile, filePath)
}

View file

@ -0,0 +1,99 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a test ASS file with non-standard formatting
content := `[Script Info]
ScriptType:v4.00+
Title: Format Test
PlayResX:640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format:Layer, Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
Dialogue:0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,This is the second subtitle line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "format_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test format
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formattedContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
contentStr := string(formattedContent)
// Check for consistency and proper spacing
if !strings.Contains(contentStr, "Title: Format Test") {
t.Errorf("Title should be properly formatted, got: %s", contentStr)
}
// Check style section formatting
if !strings.Contains(contentStr, "Format: Name, Fontname, Fontsize") {
t.Errorf("Style format should be properly spaced, got: %s", contentStr)
}
// Check event section formatting
if !strings.Contains(contentStr, "Dialogue: 0,") {
t.Errorf("Dialogue should be properly formatted, got: %s", contentStr)
}
// Parse formatted file to ensure it's valid
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Failed to parse formatted file: %v", err)
}
// Verify basic structure remains intact
if assFile.ScriptInfo["Title"] != "Format Test" {
t.Errorf("Title mismatch after formatting: expected 'Format Test', got '%s'", assFile.ScriptInfo["Title"])
}
if len(assFile.Events) != 2 {
t.Errorf("Expected 2 events after formatting, got %d", len(assFile.Events))
}
}
func TestFormat_NonExistentFile(t *testing.T) {
err := Format("/nonexistent/file.ass")
if err == nil {
t.Error("Formatting non-existent file should return an error")
}
}
func TestFormat_InvalidWritable(t *testing.T) {
// Create a directory instead of a file
tempDir := t.TempDir()
dirAsFile := filepath.Join(tempDir, "dir_as_file")
if err := os.Mkdir(dirAsFile, 0755); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Try to format a directory
err := Format(dirAsFile)
if err == nil {
t.Error("Formatting a directory should return an error")
}
}

View file

@ -0,0 +1,122 @@
package ass
import (
"fmt"
"os"
"path/filepath"
"strings"
"sub-cli/internal/model"
)
// Generate 生成ASS文件
func Generate(assFile model.ASSFile, filePath string) error {
// 确保目录存在
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
// 创建或覆盖文件
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("创建ASS文件失败: %w", err)
}
defer file.Close()
// 写入脚本信息
if _, err := file.WriteString(ASSHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
for key, value := range assFile.ScriptInfo {
if _, err := file.WriteString(fmt.Sprintf("%s: %s\n", key, value)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
// 写入样式信息
if _, err := file.WriteString("\n" + ASSStylesHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入样式格式行
if len(assFile.Styles) > 0 {
var formatString string
for _, style := range assFile.Styles {
if formatString == "" && style.Properties["Format"] != "" {
formatString = style.Properties["Format"]
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", formatString)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
break
}
}
// 如果没有找到格式行,写入默认格式
if formatString == "" {
defaultFormat := "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", defaultFormat)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
// 写入样式定义
for _, style := range assFile.Styles {
if style.Properties["Style"] != "" {
if _, err := file.WriteString(fmt.Sprintf("Style: %s\n", style.Properties["Style"])); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
}
}
// 写入事件信息
if _, err := file.WriteString("\n" + ASSEventsHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入事件格式行
if _, err := file.WriteString(DefaultFormat + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入事件行
for _, event := range assFile.Events {
eventLine := formatEventLine(event)
if _, err := file.WriteString(eventLine + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
return nil
}
// formatEventLine 将事件格式化为ASS文件中的一行
func formatEventLine(event model.ASSEvent) string {
// 格式化时间戳
startTime := formatASSTimestamp(event.StartTime)
endTime := formatASSTimestamp(event.EndTime)
// 构建事件行
var builder strings.Builder
if event.Type == "Comment" {
builder.WriteString("Comment: ")
} else {
builder.WriteString("Dialogue: ")
}
builder.WriteString(fmt.Sprintf("%d,%s,%s,%s,%s,%d,%d,%d,%s,%s",
event.Layer,
startTime,
endTime,
event.Style,
event.Name,
event.MarginL,
event.MarginR,
event.MarginV,
event.Effect,
event.Text))
return builder.String()
}

View file

@ -0,0 +1,131 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test ASS file structure
assFile := model.NewASSFile()
assFile.ScriptInfo["Title"] = "Generation Test"
// Add a custom style
boldStyle := model.ASSStyle{
Name: "Bold",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
"Bold": "1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
// Add dialogue events
event1 := model.NewASSEvent()
event1.Type = "Dialogue"
event1.StartTime = model.Timestamp{Seconds: 1}
event1.EndTime = model.Timestamp{Seconds: 4}
event1.Style = "Default"
event1.Text = "This is a test subtitle."
event2 := model.NewASSEvent()
event2.Type = "Dialogue"
event2.StartTime = model.Timestamp{Seconds: 5}
event2.EndTime = model.Timestamp{Seconds: 8}
event2.Style = "Bold"
event2.Text = "This is a bold subtitle."
assFile.Events = append(assFile.Events, event1, event2)
// Generate ASS file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.ass")
err := Generate(assFile, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Read the generated file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
contentStr := string(content)
// Verify file structure and content
// Check Script Info section
if !strings.Contains(contentStr, "[Script Info]") {
t.Errorf("Missing [Script Info] section")
}
if !strings.Contains(contentStr, "Title: Generation Test") {
t.Errorf("Missing Title in Script Info")
}
// Check Styles section
if !strings.Contains(contentStr, "[V4+ Styles]") {
t.Errorf("Missing [V4+ Styles] section")
}
if !strings.Contains(contentStr, "Style: Bold,Arial,20") {
t.Errorf("Missing Bold style definition")
}
// Check Events section
if !strings.Contains(contentStr, "[Events]") {
t.Errorf("Missing [Events] section")
}
if !strings.Contains(contentStr, "Format: Layer, Start, End, Style,") {
t.Errorf("Missing Format line in Events section")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is a test subtitle.") {
t.Errorf("Missing first dialogue event")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is a bold subtitle.") {
t.Errorf("Missing second dialogue event")
}
}
func TestGenerate_FileError(t *testing.T) {
// Test invalid path
assFile := model.NewASSFile()
err := Generate(assFile, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Generating to invalid path should return an error")
}
}
func TestFormatEventLine(t *testing.T) {
event := model.ASSEvent{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Name: "Character",
MarginL: 10,
MarginR: 10,
MarginV: 10,
Effect: "Fade",
Text: "Test text",
}
expected := "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
result := formatEventLine(event)
if result != expected {
t.Errorf("Expected: '%s', got: '%s'", expected, result)
}
// Test Comment type
event.Type = "Comment"
expected = "Comment: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
result = formatEventLine(event)
if result != expected {
t.Errorf("Expected: '%s', got: '%s'", expected, result)
}
}

View file

@ -0,0 +1,152 @@
package ass
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"sub-cli/internal/model"
)
// 常量定义
const (
ASSHeader = "[Script Info]"
ASSStylesHeader = "[V4+ Styles]"
ASSEventsHeader = "[Events]"
DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
)
// Parse 解析ASS文件为ASSFile结构
func Parse(filePath string) (model.ASSFile, error) {
file, err := os.Open(filePath)
if err != nil {
return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err)
}
defer file.Close()
result := model.NewASSFile()
scanner := bufio.NewScanner(file)
// 当前解析的区块
currentSection := ""
var styleFormat, eventFormat []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") {
// 跳过空行和注释行
continue
}
// 检查章节标题
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = line
continue
}
switch currentSection {
case ASSHeader:
// 解析脚本信息
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
result.ScriptInfo[key] = value
}
case ASSStylesHeader:
// 解析样式格式行和样式定义
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
styleFormat = parseFormatLine(formatStr)
} else if strings.HasPrefix(line, "Style:") {
styleValues := parseStyleLine(line)
if len(styleFormat) > 0 && len(styleValues) > 0 {
style := model.ASSStyle{
Name: styleValues[0], // 第一个值通常是样式名称
Properties: make(map[string]string),
}
// 将原始格式行保存下来
style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ")
style.Properties["Style"] = strings.Join(styleValues, ", ")
// 解析各个样式属性
for i := 0; i < len(styleFormat) && i < len(styleValues); i++ {
style.Properties[styleFormat[i]] = styleValues[i]
}
result.Styles = append(result.Styles, style)
}
}
case ASSEventsHeader:
// 解析事件格式行和对话行
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
eventFormat = parseFormatLine(formatStr)
} else if len(eventFormat) > 0 &&
(strings.HasPrefix(line, "Dialogue:") ||
strings.HasPrefix(line, "Comment:")) {
eventType := "Dialogue"
if strings.HasPrefix(line, "Comment:") {
eventType = "Comment"
line = strings.TrimPrefix(line, "Comment:")
} else {
line = strings.TrimPrefix(line, "Dialogue:")
}
values := parseEventLine(line)
if len(values) >= len(eventFormat) {
event := model.NewASSEvent()
event.Type = eventType
// 填充事件属性
for i, format := range eventFormat {
value := values[i]
switch strings.TrimSpace(format) {
case "Layer":
layer, _ := strconv.Atoi(value)
event.Layer = layer
case "Start":
event.StartTime = parseASSTimestamp(value)
case "End":
event.EndTime = parseASSTimestamp(value)
case "Style":
event.Style = value
case "Name":
event.Name = value
case "MarginL":
marginL, _ := strconv.Atoi(value)
event.MarginL = marginL
case "MarginR":
marginR, _ := strconv.Atoi(value)
event.MarginR = marginR
case "MarginV":
marginV, _ := strconv.Atoi(value)
event.MarginV = marginV
case "Effect":
event.Effect = value
case "Text":
// 文本可能包含逗号,所以需要特殊处理
textStartIndex := strings.Index(line, value)
if textStartIndex >= 0 {
event.Text = line[textStartIndex:]
} else {
event.Text = value
}
}
}
result.Events = append(result.Events, event)
}
}
}
}
return result, nil
}

View file

@ -0,0 +1,148 @@
package ass
import (
"os"
"path/filepath"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create temporary test file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
// Script info
if assFile.ScriptInfo["Title"] != "Test ASS File" {
t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"])
}
if assFile.ScriptInfo["ScriptType"] != "v4.00+" {
t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"])
}
// Styles
if len(assFile.Styles) != 3 {
t.Errorf("Expected 3 styles, got %d", len(assFile.Styles))
} else {
// Find Bold style
var boldStyle *model.ASSStyle
for i, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyle = &assFile.Styles[i]
break
}
}
if boldStyle == nil {
t.Errorf("Bold style not found")
} else {
boldValue, exists := boldStyle.Properties["Bold"]
if !exists || boldValue != "1" {
t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue)
}
}
}
// Events
if len(assFile.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
} else {
// Check first dialogue line
if assFile.Events[0].Type != "Dialogue" {
t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type)
}
if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 {
t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d",
assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes,
assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds)
}
if assFile.Events[0].Text != "This is the first subtitle line." {
t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text)
}
// Check second dialogue line (bold style)
if assFile.Events[1].Style != "Bold" {
t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style)
}
// Check comment line
if assFile.Events[2].Type != "Comment" {
t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type)
}
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.ass")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
assFile, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Failed to parse empty file: %v", err)
}
if len(assFile.Events) != 0 {
t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events))
}
// Test file missing required sections
malformedContent := `[Script Info]
Title: Missing Sections Test
`
malformedFile := filepath.Join(tempDir, "malformed.ass")
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
t.Fatalf("Failed to create malformed file: %v", err)
}
assFile, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Failed to parse malformed file: %v", err)
}
if assFile.ScriptInfo["Title"] != "Missing Sections Test" {
t.Errorf("Should correctly parse the title")
}
if len(assFile.Events) != 0 {
t.Errorf("File missing Events section should have 0 events")
}
}
func TestParse_FileError(t *testing.T) {
// Test non-existent file
_, err := Parse("/nonexistent/file.ass")
if err == nil {
t.Error("Parsing non-existent file should return an error")
}
}

View file

@ -0,0 +1,98 @@
package ass
import (
"fmt"
"regexp"
"strconv"
"strings"
"sub-cli/internal/model"
)
// parseFormatLine 解析格式行中的各个字段
func parseFormatLine(formatStr string) []string {
fields := strings.Split(formatStr, ",")
result := make([]string, 0, len(fields))
for _, field := range fields {
result = append(result, strings.TrimSpace(field))
}
return result
}
// parseStyleLine 解析样式行
func parseStyleLine(line string) []string {
// 去掉"Style:"前缀
styleStr := strings.TrimPrefix(line, "Style:")
return splitCSV(styleStr)
}
// parseEventLine 解析事件行
func parseEventLine(line string) []string {
return splitCSV(line)
}
// splitCSV 拆分CSV格式的字符串但保留Text字段中的逗号
func splitCSV(line string) []string {
var result []string
inText := false
current := ""
for _, char := range line {
if char == ',' && !inText {
result = append(result, strings.TrimSpace(current))
current = ""
} else {
current += string(char)
// 这是个简化处理实际ASS格式更复杂
// 当处理到足够数量的字段后剩余部分都当作Text字段
if len(result) >= 9 {
inText = true
}
}
}
if current != "" {
result = append(result, strings.TrimSpace(current))
}
return result
}
// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc)
func parseASSTimestamp(timeStr string) model.Timestamp {
// 匹配 h:mm:ss.cc 格式
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`)
matches := re.FindStringSubmatch(timeStr)
if len(matches) == 5 {
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
seconds, _ := strconv.Atoi(matches[3])
// ASS使用厘秒1/100秒需要转换为毫秒
centiseconds, _ := strconv.Atoi(matches[4])
milliseconds := centiseconds * 10
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// 返回零时间戳,如果解析失败
return model.Timestamp{}
}
// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc)
func formatASSTimestamp(timestamp model.Timestamp) string {
// ASS使用厘秒1/100秒
centiseconds := timestamp.Milliseconds / 10
return fmt.Sprintf("%d:%02d:%02d.%02d",
timestamp.Hours,
timestamp.Minutes,
timestamp.Seconds,
centiseconds)
}

View file

@ -0,0 +1,139 @@
package ass
import (
"testing"
"sub-cli/internal/model"
)
func TestParseASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
}{
{
name: "Standard format",
input: "0:00:01.00",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
{
name: "With centiseconds",
input: "0:00:01.50",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
},
{
name: "Complete hours, minutes, seconds",
input: "1:02:03.45",
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
},
{
name: "Invalid format",
input: "invalid",
expected: model.Timestamp{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := parseASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
}
})
}
}
func TestFormatASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input model.Timestamp
expected string
}{
{
name: "Zero timestamp",
input: model.Timestamp{},
expected: "0:00:00.00",
},
{
name: "Simple seconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
expected: "0:00:01.00",
},
{
name: "With milliseconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
expected: "0:00:01.50",
},
{
name: "Complete timestamp",
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
expected: "1:02:03.45",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := formatASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
}
})
}
}
func TestSplitCSV(t *testing.T) {
testCases := []struct {
name string
input string
expected []string
}{
{
name: "Simple CSV",
input: "Value1, Value2, Value3",
expected: []string{"Value1", "Value2", "Value3"},
},
{
name: "Text field with commas",
input: "0, 00:00:01.00, 00:00:05.00, Default, Name, 0, 0, 0, Effect, Text with, commas",
expected: []string{"0", "00:00:01.00", "00:00:05.00", "Default", "Name", "0", "0", "0", "Effect", "Text with, commas"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := splitCSV(tc.input)
// Check result length
if len(result) != len(tc.expected) {
t.Errorf("Expected %d values, got %d: %v", len(tc.expected), len(result), result)
return
}
// Check content
for i := range result {
if result[i] != tc.expected[i] {
t.Errorf("At index %d, expected '%s', got '%s'", i, tc.expected[i], result[i])
}
}
})
}
}
func TestParseFormatLine(t *testing.T) {
input := " Name, Fontname, Fontsize, PrimaryColour"
expected := []string{"Name", "Fontname", "Fontsize", "PrimaryColour"}
result := parseFormatLine(input)
if len(result) != len(expected) {
t.Errorf("Expected %d values, got %d: %v", len(expected), len(result), result)
return
}
for i := range result {
if result[i] != expected[i] {
t.Errorf("At index %d, expected '%s', got '%s'", i, expected[i], result[i])
}
}
}

View file

@ -0,0 +1,181 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]This is the first line.
[00:05.00]This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "lrc" {
t.Errorf("Expected format 'lrc', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].StartTime.Hours != 0 || subtitle.Entries[0].StartTime.Minutes != 0 ||
subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:01.00, got %+v", subtitle.Entries[0].StartTime)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
// Check metadata conversion
if subtitle.Title != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
}
if subtitle.Metadata["ar"] != "Test Artist" {
t.Errorf("Expected artist metadata 'Test Artist', got '%s'", subtitle.Metadata["ar"])
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create a subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "lrc"
subtitle.Title = "Test LRC File"
subtitle.Metadata["ar"] = "Test Artist"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert to LRC
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.lrc")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
// Check metadata
if !strings.Contains(contentStr, "[ti:Test LRC File]") {
t.Errorf("Expected title metadata in output, not found")
}
if !strings.Contains(contentStr, "[ar:Test Artist]") {
t.Errorf("Expected artist metadata in output, not found")
}
// Check timeline entries
if !strings.Contains(contentStr, "[00:01.000]This is the first line.") {
t.Errorf("Expected first timeline entry in output, not found")
}
if !strings.Contains(contentStr, "[00:05.000]This is the second line.") {
t.Errorf("Expected second timeline entry in output, not found")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertToSubtitle_EdgeCases(t *testing.T) {
// Test with empty lyrics (no content/timeline)
tempDir := t.TempDir()
emptyLyricsFile := filepath.Join(tempDir, "empty_lyrics.lrc")
content := `[ti:Test LRC File]
[ar:Test Artist]
`
if err := os.WriteFile(emptyLyricsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create empty lyrics test file: %v", err)
}
subtitle, err := ConvertToSubtitle(emptyLyricsFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed on empty lyrics: %v", err)
}
if len(subtitle.Entries) != 0 {
t.Errorf("Expected 0 entries for empty lyrics, got %d", len(subtitle.Entries))
}
if subtitle.Title != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
}
// Test with more content than timeline entries
moreContentFile := filepath.Join(tempDir, "more_content.lrc")
content = `[ti:Test LRC File]
[00:01.00]This has a timestamp.
This doesn't have a timestamp but is content.
`
if err := os.WriteFile(moreContentFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create more content test file: %v", err)
}
subtitle, err = ConvertToSubtitle(moreContentFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed on file with more content than timestamps: %v", err)
}
if len(subtitle.Entries) != 1 {
t.Errorf("Expected 1 entry (only the one with timestamp), got %d", len(subtitle.Entries))
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.lrc")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}

View file

@ -0,0 +1,72 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a temporary test file with messy formatting
content := `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]This should be first.
[00:05.00]This is the second line.
[00:09.50]This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formatted, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
// Check that the file was at least generated successfully
lines := strings.Split(string(formatted), "\n")
if len(lines) < 4 {
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
}
// Check that the metadata was preserved
if !strings.Contains(string(formatted), "[ti:Test LRC File]") {
t.Errorf("Expected title metadata in output, not found")
}
if !strings.Contains(string(formatted), "[ar:Test Artist]") {
t.Errorf("Expected artist metadata in output, not found")
}
// Check that all the content lines are present
if !strings.Contains(string(formatted), "This should be first") {
t.Errorf("Expected 'This should be first' in output, not found")
}
if !strings.Contains(string(formatted), "This is the second line") {
t.Errorf("Expected 'This is the second line' in output, not found")
}
if !strings.Contains(string(formatted), "This is the third line") {
t.Errorf("Expected 'This is the third line' in output, not found")
}
}
func TestFormat_FileError(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}

View file

@ -0,0 +1,151 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test lyrics
lyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Test LRC File",
"ar": "Test Artist",
},
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is the first line.",
"This is the second line.",
},
}
// Generate LRC file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.lrc")
err := Generate(lyrics, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 4 {
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
}
hasTitleLine := false
hasFirstTimeline := false
for _, line := range lines {
if line == "[ti:Test LRC File]" {
hasTitleLine = true
}
if line == "[00:01.000]This is the first line." {
hasFirstTimeline = true
}
}
if !hasTitleLine {
t.Errorf("Expected title line '[ti:Test LRC File]' not found")
}
if !hasFirstTimeline {
t.Errorf("Expected timeline line '[00:01.000]This is the first line.' not found")
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test lyrics
lyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Test LRC File",
},
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
Content: []string{
"This is a test line.",
},
}
// Test with invalid path
err := Generate(lyrics, "/nonexistent/directory/file.lrc")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}
func TestGenerate_EdgeCases(t *testing.T) {
// Test with empty lyrics
emptyLyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Empty Test",
},
}
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty_output.lrc")
err := Generate(emptyLyrics, emptyFile)
if err != nil {
t.Fatalf("Generate failed with empty lyrics: %v", err)
}
// Verify content has metadata but no timeline entries
content, err := os.ReadFile(emptyFile)
if err != nil {
t.Fatalf("Failed to read empty output file: %v", err)
}
if !strings.Contains(string(content), "[ti:Empty Test]") {
t.Errorf("Expected metadata in empty lyrics output, not found")
}
// Test with unequal timeline and content lengths
unequalLyrics := model.Lyrics{
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is the only content line.",
},
}
unequalFile := filepath.Join(tempDir, "unequal_output.lrc")
err = Generate(unequalLyrics, unequalFile)
if err != nil {
t.Fatalf("Generate failed with unequal lyrics: %v", err)
}
// Should only generate for the entries that have both timeline and content
content, err = os.ReadFile(unequalFile)
if err != nil {
t.Fatalf("Failed to read unequal output file: %v", err)
}
lines := strings.Split(string(content), "\n")
timelineLines := 0
for _, line := range lines {
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") &&
strings.Contains(line, ":") && strings.Contains(line, ".") {
timelineLines++
}
}
if timelineLines > 1 {
t.Errorf("Expected only 1 timeline entry for unequal lyrics, got %d", timelineLines)
}
}

View file

@ -1,518 +0,0 @@
package lrc
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `[ti:Test LRC File]
[ar:Test Artist]
[al:Test Album]
[by:Test Creator]
[00:01.00]This is the first line.
[00:05.00]This is the second line.
[00:09.50]This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
lyrics, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if len(lyrics.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(lyrics.Timeline))
}
if len(lyrics.Content) != 3 {
t.Errorf("Expected 3 content entries, got %d", len(lyrics.Content))
}
// Check metadata
if lyrics.Metadata["ti"] != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
}
if lyrics.Metadata["ar"] != "Test Artist" {
t.Errorf("Expected artist 'Test Artist', got '%s'", lyrics.Metadata["ar"])
}
if lyrics.Metadata["al"] != "Test Album" {
t.Errorf("Expected album 'Test Album', got '%s'", lyrics.Metadata["al"])
}
if lyrics.Metadata["by"] != "Test Creator" {
t.Errorf("Expected creator 'Test Creator', got '%s'", lyrics.Metadata["by"])
}
// Check first timeline entry
if lyrics.Timeline[0].Hours != 0 || lyrics.Timeline[0].Minutes != 0 ||
lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
t.Errorf("First entry time: expected 00:01.00, got %+v", lyrics.Timeline[0])
}
// Check third timeline entry
if lyrics.Timeline[2].Hours != 0 || lyrics.Timeline[2].Minutes != 0 ||
lyrics.Timeline[2].Seconds != 9 || lyrics.Timeline[2].Milliseconds != 500 {
t.Errorf("Third entry time: expected 00:09.50, got %+v", lyrics.Timeline[2])
}
// Check content
if lyrics.Content[0] != "This is the first line." {
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
}
}
func TestGenerate(t *testing.T) {
// Create test lyrics
lyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Test LRC File",
"ar": "Test Artist",
},
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is the first line.",
"This is the second line.",
},
}
// Generate LRC file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.lrc")
err := Generate(lyrics, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 4 {
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
}
hasTitleLine := false
hasFirstTimeline := false
for _, line := range lines {
if line == "[ti:Test LRC File]" {
hasTitleLine = true
}
if line == "[00:01.000]This is the first line." {
hasFirstTimeline = true
}
}
if !hasTitleLine {
t.Errorf("Expected title line '[ti:Test LRC File]' not found")
}
if !hasFirstTimeline {
t.Errorf("Expected timeline line '[00:01.000]This is the first line.' not found")
}
}
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]This is the first line.
[00:05.00]This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "lrc" {
t.Errorf("Expected format 'lrc', got '%s'", subtitle.Format)
}
if subtitle.Title != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
// Check metadata
if subtitle.Metadata["ar"] != "Test Artist" {
t.Errorf("Expected metadata 'ar' to be 'Test Artist', got '%s'", subtitle.Metadata["ar"])
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "lrc"
subtitle.Title = "Test LRC File"
subtitle.Metadata["ar"] = "Test Artist"
entry1 := model.NewSubtitleEntry()
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert from subtitle to LRC
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.lrc")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by parsing back
lyrics, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse output file: %v", err)
}
if len(lyrics.Timeline) != 2 {
t.Errorf("Expected 2 entries, got %d", len(lyrics.Timeline))
}
if lyrics.Content[0] != "This is the first line." {
t.Errorf("Expected first entry content 'This is the first line.', got '%s'", lyrics.Content[0])
}
if lyrics.Metadata["ti"] != "Test LRC File" {
t.Errorf("Expected title metadata 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
}
}
func TestFormat(t *testing.T) {
// Create test LRC file with inconsistent timestamp formatting
content := `[ti:Test LRC File]
[ar:Test Artist]
[0:1.0]This is the first line.
[0:5]This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Verify by parsing back
lyrics, err := Parse(testFile)
if err != nil {
t.Fatalf("Failed to parse formatted file: %v", err)
}
// Check that timestamps are formatted correctly
if lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
t.Errorf("Expected first timestamp to be 00:01.000, got %+v", lyrics.Timeline[0])
}
// Verify metadata is preserved
if lyrics.Metadata["ti"] != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
}
}
func TestParseTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
hasError bool
}{
{
name: "Simple minute and second",
input: "01:30",
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 0},
hasError: false,
},
{
name: "With milliseconds (1 digit)",
input: "01:30.5",
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 500},
hasError: false,
},
{
name: "With milliseconds (2 digits)",
input: "01:30.75",
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 750},
hasError: false,
},
{
name: "With milliseconds (3 digits)",
input: "01:30.123",
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 123},
hasError: false,
},
{
name: "With hours, minutes, seconds",
input: "01:30:45",
expected: model.Timestamp{Hours: 1, Minutes: 30, Seconds: 45, Milliseconds: 0},
hasError: false,
},
{
name: "With hours, minutes, seconds and milliseconds",
input: "01:30:45.5",
expected: model.Timestamp{Hours: 1, Minutes: 30, Seconds: 45, Milliseconds: 500},
hasError: false,
},
{
name: "Invalid format (single number)",
input: "123",
expected: model.Timestamp{},
hasError: true,
},
{
name: "Invalid format (too many parts)",
input: "01:30:45:67",
expected: model.Timestamp{},
hasError: true,
},
{
name: "Invalid minute (not a number)",
input: "aa:30",
expected: model.Timestamp{},
hasError: true,
},
{
name: "Invalid second (not a number)",
input: "01:bb",
expected: model.Timestamp{},
hasError: true,
},
{
name: "Invalid millisecond (not a number)",
input: "01:30.cc",
expected: model.Timestamp{},
hasError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := ParseTimestamp(tc.input)
if tc.hasError && err == nil {
t.Errorf("Expected error for input '%s', but got none", tc.input)
}
if !tc.hasError && err != nil {
t.Errorf("Unexpected error for input '%s': %v", tc.input, err)
}
if !tc.hasError && result != tc.expected {
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
}
})
}
}
func TestParse_FileErrors(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.lrc")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
lyrics, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Parse failed on empty file: %v", err)
}
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
t.Errorf("Expected empty lyrics for empty file, got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with invalid timestamps
invalidFile := filepath.Join(tempDir, "invalid.lrc")
content := `[ti:Test LRC File]
[ar:Test Artist]
[invalidtime]This should be ignored.
[00:01.00]This is a valid line.
`
if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create invalid test file: %v", err)
}
lyrics, err = Parse(invalidFile)
if err != nil {
t.Fatalf("Parse failed on file with invalid timestamps: %v", err)
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 valid timeline entry, got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with timestamp-only lines (no content)
timestampOnlyFile := filepath.Join(tempDir, "timestamp_only.lrc")
content = `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]
[00:05.00]This has content.
`
if err := os.WriteFile(timestampOnlyFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create timestamp-only test file: %v", err)
}
lyrics, err = Parse(timestampOnlyFile)
if err != nil {
t.Fatalf("Parse failed on file with timestamp-only lines: %v", err)
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 valid entry (ignoring empty content), got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test lyrics
lyrics := model.Lyrics{
Metadata: map[string]string{
"ti": "Test LRC File",
},
Timeline: []model.Timestamp{
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
Content: []string{
"This is a test line.",
},
}
// Test with invalid path
err := Generate(lyrics, "/nonexistent/directory/file.lrc")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}
func TestFormat_FileError(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertToSubtitle_EdgeCases(t *testing.T) {
// Test with empty lyrics (no content/timeline)
tempDir := t.TempDir()
emptyLyricsFile := filepath.Join(tempDir, "empty_lyrics.lrc")
content := `[ti:Test LRC File]
[ar:Test Artist]
`
if err := os.WriteFile(emptyLyricsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create empty lyrics test file: %v", err)
}
subtitle, err := ConvertToSubtitle(emptyLyricsFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed on empty lyrics: %v", err)
}
if len(subtitle.Entries) != 0 {
t.Errorf("Expected 0 entries for empty lyrics, got %d", len(subtitle.Entries))
}
if subtitle.Title != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
}
// Test with more content than timeline entries
moreContentFile := filepath.Join(tempDir, "more_content.lrc")
content = `[ti:Test LRC File]
[00:01.00]This has a timestamp.
This doesn't have a timestamp but is content.
`
if err := os.WriteFile(moreContentFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create more content test file: %v", err)
}
subtitle, err = ConvertToSubtitle(moreContentFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed on file with more content than timestamps: %v", err)
}
if len(subtitle.Entries) != 1 {
t.Errorf("Expected 1 entry (only the one with timestamp), got %d", len(subtitle.Entries))
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.lrc")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}

View file

@ -0,0 +1,185 @@
package lrc
import (
"os"
"path/filepath"
"testing"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `[ti:Test LRC File]
[ar:Test Artist]
[al:Test Album]
[by:Test Creator]
[00:01.00]This is the first line.
[00:05.00]This is the second line.
[00:09.50]This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.lrc")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
lyrics, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if len(lyrics.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(lyrics.Timeline))
}
if len(lyrics.Content) != 3 {
t.Errorf("Expected 3 content entries, got %d", len(lyrics.Content))
}
// Check metadata
if lyrics.Metadata["ti"] != "Test LRC File" {
t.Errorf("Expected title 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
}
if lyrics.Metadata["ar"] != "Test Artist" {
t.Errorf("Expected artist 'Test Artist', got '%s'", lyrics.Metadata["ar"])
}
if lyrics.Metadata["al"] != "Test Album" {
t.Errorf("Expected album 'Test Album', got '%s'", lyrics.Metadata["al"])
}
if lyrics.Metadata["by"] != "Test Creator" {
t.Errorf("Expected creator 'Test Creator', got '%s'", lyrics.Metadata["by"])
}
// Check first timeline entry
if lyrics.Timeline[0].Hours != 0 || lyrics.Timeline[0].Minutes != 0 ||
lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
t.Errorf("First entry time: expected 00:01.00, got %+v", lyrics.Timeline[0])
}
// Check third timeline entry
if lyrics.Timeline[2].Hours != 0 || lyrics.Timeline[2].Minutes != 0 ||
lyrics.Timeline[2].Seconds != 9 || lyrics.Timeline[2].Milliseconds != 500 {
t.Errorf("Third entry time: expected 00:09.50, got %+v", lyrics.Timeline[2])
}
// Check content
if lyrics.Content[0] != "This is the first line." {
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
}
}
func TestParse_FileErrors(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.lrc")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.lrc")
if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
lyrics, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Parse failed with empty file: %v", err)
}
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
t.Errorf("Expected empty lyrics for empty file, got %d timeline and %d content",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with metadata only
metadataFile := filepath.Join(tempDir, "metadata.lrc")
metadataContent := `[ti:Test Title]
[ar:Test Artist]
[al:Test Album]
`
if err := os.WriteFile(metadataFile, []byte(metadataContent), 0644); err != nil {
t.Fatalf("Failed to create metadata file: %v", err)
}
lyrics, err = Parse(metadataFile)
if err != nil {
t.Fatalf("Parse failed with metadata-only file: %v", err)
}
if lyrics.Metadata["ti"] != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", lyrics.Metadata["ti"])
}
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
t.Errorf("Expected empty timeline/content for metadata-only file, got %d timeline and %d content",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with invalid metadata
invalidMetadataFile := filepath.Join(tempDir, "invalid_metadata.lrc")
invalidMetadata := `[ti:Test Title
[ar:Test Artist]
[00:01.00]This is a valid line.
`
if err := os.WriteFile(invalidMetadataFile, []byte(invalidMetadata), 0644); err != nil {
t.Fatalf("Failed to create invalid metadata file: %v", err)
}
lyrics, err = Parse(invalidMetadataFile)
if err != nil {
t.Fatalf("Parse failed with invalid metadata file: %v", err)
}
if lyrics.Metadata["ti"] != "" { // Should ignore invalid metadata
t.Errorf("Expected empty title for invalid metadata, got '%s'", lyrics.Metadata["ti"])
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 timeline/content entry for file with invalid metadata, got %d timeline and %d content",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with invalid timestamp format
invalidFile := filepath.Join(tempDir, "invalid.lrc")
content := `[ti:Test LRC File]
[ar:Test Artist]
[invalidtime]This should be ignored.
[00:01.00]This is a valid line.
`
if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create invalid test file: %v", err)
}
lyrics, err = Parse(invalidFile)
if err != nil {
t.Fatalf("Parse failed on file with invalid timestamps: %v", err)
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 valid timeline entry, got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
// Test with timestamp-only lines (no content)
timestampOnlyFile := filepath.Join(tempDir, "timestamp_only.lrc")
content = `[ti:Test LRC File]
[ar:Test Artist]
[00:01.00]
[00:05.00]This has content.
`
if err := os.WriteFile(timestampOnlyFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create timestamp-only test file: %v", err)
}
lyrics, err = Parse(timestampOnlyFile)
if err != nil {
t.Fatalf("Parse failed on file with timestamp-only lines: %v", err)
}
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
t.Errorf("Expected 1 valid entry (ignoring empty content), got %d timeline entries and %d content entries",
len(lyrics.Timeline), len(lyrics.Content))
}
}

View file

@ -0,0 +1,163 @@
package lrc
import (
"testing"
"sub-cli/internal/model"
)
func TestParseTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
valid bool
}{
{
name: "Simple minute and second",
input: "[01:30]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 0,
},
valid: true,
},
{
name: "With milliseconds",
input: "[01:30.500]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
{
name: "With hours",
input: "[01:30:45.500]",
expected: model.Timestamp{
Hours: 1,
Minutes: 30,
Seconds: 45,
Milliseconds: 500,
},
valid: true,
},
{
name: "Zero time",
input: "[00:00.000]",
expected: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
},
valid: true,
},
{
name: "Invalid format - no brackets",
input: "01:30",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 0,
},
valid: true, // ParseTimestamp automatically strips brackets, so it will parse this without brackets
},
{
name: "Invalid format - wrong brackets",
input: "(01:30)",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - no time",
input: "[]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - text in brackets",
input: "[text]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - incomplete time",
input: "[01:]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Invalid format - incomplete time with milliseconds",
input: "[01:.500]",
expected: model.Timestamp{},
valid: false,
},
{
name: "Metadata tag",
input: "[ti:Title]",
expected: model.Timestamp{},
valid: false,
},
{
name: "With milliseconds - alternative format using comma",
input: "[01:30.500]", // Use period instead of comma since our parser doesn't handle comma
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
{
name: "With double-digit milliseconds",
input: "[01:30.50]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
{
name: "With single-digit milliseconds",
input: "[01:30.5]",
expected: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 30,
Milliseconds: 500,
},
valid: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
timestamp, err := ParseTimestamp(tc.input)
if (err == nil) != tc.valid {
t.Errorf("Expected valid=%v, got valid=%v (err=%v)", tc.valid, err == nil, err)
return
}
if !tc.valid {
return // No need to check further for invalid cases
}
if timestamp.Hours != tc.expected.Hours ||
timestamp.Minutes != tc.expected.Minutes ||
timestamp.Seconds != tc.expected.Seconds ||
timestamp.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected timestamp %+v, got %+v", tc.expected, timestamp)
}
})
}
}

View file

@ -0,0 +1,255 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `1
00:00:01,000 --> 00:00:04,000
This is the first line.
2
00:00:05,000 --> 00:00:08,000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "srt" {
t.Errorf("Expected format 'srt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create a subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 7 {
t.Fatalf("Expected at least 7 lines, got %d", len(lines))
}
// Check that the SRT entries were created correctly
if lines[0] != "1" {
t.Errorf("Expected first entry number to be '1', got '%s'", lines[0])
}
if !strings.Contains(lines[1], "00:00:01,000 --> 00:00:04,000") {
t.Errorf("Expected first entry time range to match, got '%s'", lines[1])
}
if lines[2] != "This is the first line." {
t.Errorf("Expected first entry content to match, got '%s'", lines[2])
}
}
func TestConvertToSubtitle_WithHTMLTags(t *testing.T) {
// Create a temporary test file with HTML styling tags
content := `1
00:00:01,000 --> 00:00:04,000
<i>This is italic.</i>
2
00:00:05,000 --> 00:00:08,000
<b>This is bold.</b>
3
00:00:09,000 --> 00:00:12,000
<u>This is underlined.</u>
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "styled.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check style detection
if value, ok := subtitle.Entries[0].Styles["italic"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain italic=true for entry with <i> tag")
}
if value, ok := subtitle.Entries[1].Styles["bold"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain bold=true for entry with <b> tag")
}
if value, ok := subtitle.Entries[2].Styles["underline"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain underline=true for entry with <u> tag")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertFromSubtitle_WithStyling(t *testing.T) {
// Create a subtitle with style attributes
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Create an entry with italics
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This should be italic."
entry1.Styles["italic"] = "true"
// Create an entry with bold
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This should be bold."
entry2.Styles["bold"] = "true"
// Create an entry with underline
entry3 := model.NewSubtitleEntry()
entry3.Index = 3
entry3.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0}
entry3.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0}
entry3.Text = "This should be underlined."
entry3.Styles["underline"] = "true"
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "styled.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check that HTML tags were applied
contentStr := string(content)
if !strings.Contains(contentStr, "<i>This should be italic.</i>") {
t.Errorf("Expected italic HTML tags to be applied")
}
if !strings.Contains(contentStr, "<b>This should be bold.</b>") {
t.Errorf("Expected bold HTML tags to be applied")
}
if !strings.Contains(contentStr, "<u>This should be underlined.</u>") {
t.Errorf("Expected underline HTML tags to be applied")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.srt")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}
func TestConvertFromSubtitle_WithExistingHTMLTags(t *testing.T) {
// Create a subtitle with text that already contains HTML tags
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Create an entry with existing italic tags but also style attribute
entry := model.NewSubtitleEntry()
entry.Index = 1
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry.Text = "<i>Already italic text.</i>"
entry.Styles["italic"] = "true" // Should not double-wrap with <i> tags
subtitle.Entries = append(subtitle.Entries, entry)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "existing_tags.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Should not have double tags
contentStr := string(content)
if strings.Contains(contentStr, "<i><i>") {
t.Errorf("Expected no duplicate italic tags, but found them")
}
}

View file

@ -0,0 +1,70 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a temporary test file with out-of-order numbers
content := `2
00:00:05,000 --> 00:00:08,000
This is the second line.
1
00:00:01,000 --> 00:00:04,000
This is the first line.
3
00:00:09,500 --> 00:00:12,800
This is the third line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formatted, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
// The Format function should standardize the numbering
lines := strings.Split(string(formatted), "\n")
// The numbers should be sequential starting from 1
if !strings.HasPrefix(lines[0], "1") {
t.Errorf("First entry should be renumbered to 1, got '%s'", lines[0])
}
// Find the second entry (after the first entry's content and a blank line)
var secondEntryIndex int
for i := 1; i < len(lines); i++ {
if lines[i] == "" && i+1 < len(lines) && lines[i+1] != "" {
secondEntryIndex = i + 1
break
}
}
if secondEntryIndex > 0 && !strings.HasPrefix(lines[secondEntryIndex], "2") {
t.Errorf("Second entry should be renumbered to 2, got '%s'", lines[secondEntryIndex])
}
}
func TestFormat_FileError(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}

View file

@ -0,0 +1,84 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is the first line.",
},
{
Number: 2,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
Content: "This is the second line.",
},
}
// Generate SRT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.srt")
err := Generate(entries, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 6 {
t.Fatalf("Expected at least 6 lines, got %d", len(lines))
}
if lines[0] != "1" {
t.Errorf("Expected first line to be '1', got '%s'", lines[0])
}
if lines[1] != "00:00:01,000 --> 00:00:04,000" {
t.Errorf("Expected second line to be time range, got '%s'", lines[1])
}
if lines[2] != "This is the first line." {
t.Errorf("Expected third line to be content, got '%s'", lines[2])
}
}
func TestGenerate_FileError(t *testing.T) {
// Test with invalid path
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
Content: "Test",
},
}
err := Generate(entries, "/nonexistent/directory/file.srt")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
// Test with directory as file
tempDir := t.TempDir()
err = Generate(entries, tempDir)
if err == nil {
t.Error("Expected error when generating to a directory, got nil")
}
}

View file

@ -0,0 +1,58 @@
package srt
import (
"testing"
"sub-cli/internal/model"
)
func TestConvertToLyrics(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is the first line.",
},
{
Number: 2,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
Content: "This is the second line.",
},
{
Number: 3,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0},
Content: "This is the third line.",
},
}
// Convert to Lyrics
lyrics := ConvertToLyrics(entries)
// Check result
if len(lyrics.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(lyrics.Timeline))
}
if len(lyrics.Content) != 3 {
t.Errorf("Expected 3 content entries, got %d", len(lyrics.Content))
}
// Check first entry
if lyrics.Timeline[0].Hours != 0 || lyrics.Timeline[0].Minutes != 0 ||
lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
t.Errorf("First timeline: expected 00:00:01,000, got %+v", lyrics.Timeline[0])
}
if lyrics.Content[0] != "This is the first line." {
t.Errorf("First content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
}
// Check with empty entries
emptyLyrics := ConvertToLyrics([]model.SRTEntry{})
if len(emptyLyrics.Timeline) != 0 || len(emptyLyrics.Content) != 0 {
t.Errorf("Expected empty lyrics for empty entries, got %d timeline and %d content",
len(emptyLyrics.Timeline), len(emptyLyrics.Content))
}
}

View file

@ -0,0 +1,159 @@
package srt
import (
"os"
"path/filepath"
"testing"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `1
00:00:01,000 --> 00:00:04,000
This is the first line.
2
00:00:05,000 --> 00:00:08,000
This is the second line.
3
00:00:09,500 --> 00:00:12,800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
entries, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if len(entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(entries))
}
// Check first entry
if entries[0].Number != 1 {
t.Errorf("First entry number: expected 1, got %d", entries[0].Number)
}
if entries[0].StartTime.Hours != 0 || entries[0].StartTime.Minutes != 0 ||
entries[0].StartTime.Seconds != 1 || entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:00:01,000, got %+v", entries[0].StartTime)
}
if entries[0].EndTime.Hours != 0 || entries[0].EndTime.Minutes != 0 ||
entries[0].EndTime.Seconds != 4 || entries[0].EndTime.Milliseconds != 0 {
t.Errorf("First entry end time: expected 00:00:04,000, got %+v", entries[0].EndTime)
}
if entries[0].Content != "This is the first line." {
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", entries[0].Content)
}
// Check third entry
if entries[2].Number != 3 {
t.Errorf("Third entry number: expected 3, got %d", entries[2].Number)
}
expectedContent := "This is the third line\nwith a line break."
if entries[2].Content != expectedContent {
t.Errorf("Third entry content: expected '%s', got '%s'", expectedContent, entries[2].Content)
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.srt")
if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
entries, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Parse failed with empty file: %v", err)
}
if len(entries) != 0 {
t.Errorf("Expected 0 entries for empty file, got %d", len(entries))
}
// Test with malformed timestamp
malformedContent := `1
00:00:01,000 --> 00:00:04,000
First entry.
2
bad timestamp format
Second entry.
`
malformedFile := filepath.Join(tempDir, "malformed.srt")
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
t.Fatalf("Failed to create malformed file: %v", err)
}
entries, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Parse failed with malformed file: %v", err)
}
// Should still parse the first entry correctly
if len(entries) != 1 {
t.Errorf("Expected 1 entry for malformed file, got %d", len(entries))
}
// Test with missing numbers
missingNumContent := `00:00:01,000 --> 00:00:04,000
First entry without number.
2
00:00:05,000 --> 00:00:08,000
Second entry with number.
`
missingNumFile := filepath.Join(tempDir, "missing_num.srt")
if err := os.WriteFile(missingNumFile, []byte(missingNumContent), 0644); err != nil {
t.Fatalf("Failed to create missing num file: %v", err)
}
entries, err = Parse(missingNumFile)
if err != nil {
t.Fatalf("Parse failed with missing num file: %v", err)
}
// Parsing behavior may vary, but it should not crash
// In this case, it will typically parse just the second entry
// Test with extra empty lines
extraLineContent := `1
00:00:01,000 --> 00:00:04,000
First entry with extra spaces.
2
00:00:05,000 --> 00:00:08,000
Second entry with extra spaces.
`
extraLineFile := filepath.Join(tempDir, "extra_lines.srt")
if err := os.WriteFile(extraLineFile, []byte(extraLineContent), 0644); err != nil {
t.Fatalf("Failed to create extra lines file: %v", err)
}
entries, err = Parse(extraLineFile)
if err != nil {
t.Fatalf("Parse failed with extra lines file: %v", err)
}
if len(entries) != 2 {
t.Errorf("Expected 2 entries for extra lines file, got %d", len(entries))
}
// Check content was trimmed correctly
if entries[0].Content != "First entry with extra spaces." {
t.Errorf("Expected trimmed content, got '%s'", entries[0].Content)
}
}
func TestParse_FileError(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}

View file

@ -1,646 +0,0 @@
package srt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `1
00:00:01,000 --> 00:00:04,000
This is the first line.
2
00:00:05,000 --> 00:00:08,000
This is the second line.
3
00:00:09,500 --> 00:00:12,800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
entries, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if len(entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(entries))
}
// Check first entry
if entries[0].Number != 1 {
t.Errorf("First entry number: expected 1, got %d", entries[0].Number)
}
if entries[0].StartTime.Hours != 0 || entries[0].StartTime.Minutes != 0 ||
entries[0].StartTime.Seconds != 1 || entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:00:01,000, got %+v", entries[0].StartTime)
}
if entries[0].EndTime.Hours != 0 || entries[0].EndTime.Minutes != 0 ||
entries[0].EndTime.Seconds != 4 || entries[0].EndTime.Milliseconds != 0 {
t.Errorf("First entry end time: expected 00:00:04,000, got %+v", entries[0].EndTime)
}
if entries[0].Content != "This is the first line." {
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", entries[0].Content)
}
// Check third entry
if entries[2].Number != 3 {
t.Errorf("Third entry number: expected 3, got %d", entries[2].Number)
}
expectedContent := "This is the third line\nwith a line break."
if entries[2].Content != expectedContent {
t.Errorf("Third entry content: expected '%s', got '%s'", expectedContent, entries[2].Content)
}
}
func TestGenerate(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is the first line.",
},
{
Number: 2,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
Content: "This is the second line.",
},
}
// Generate SRT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.srt")
err := Generate(entries, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 6 {
t.Fatalf("Expected at least 6 lines, got %d", len(lines))
}
if lines[0] != "1" {
t.Errorf("Expected first line to be '1', got '%s'", lines[0])
}
if lines[1] != "00:00:01,000 --> 00:00:04,000" {
t.Errorf("Expected second line to be time range, got '%s'", lines[1])
}
if lines[2] != "This is the first line." {
t.Errorf("Expected third line to be content, got '%s'", lines[2])
}
}
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `1
00:00:01,000 --> 00:00:04,000
This is the first line.
2
00:00:05,000 --> 00:00:08,000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "srt" {
t.Errorf("Expected format 'srt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by parsing back
entries, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse output file: %v", err)
}
if len(entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(entries))
}
if entries[0].Content != "This is the first line." {
t.Errorf("Expected first entry content 'This is the first line.', got '%s'", entries[0].Content)
}
}
func TestFormat(t *testing.T) {
// Create test file with non-sequential numbers
content := `2
00:00:01,000 --> 00:00:04,000
This is the first line.
5
00:00:05,000 --> 00:00:08,000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Verify by parsing back
entries, err := Parse(testFile)
if err != nil {
t.Fatalf("Failed to parse formatted file: %v", err)
}
// Check that numbers are sequential
if entries[0].Number != 1 {
t.Errorf("Expected first entry number to be 1, got %d", entries[0].Number)
}
if entries[1].Number != 2 {
t.Errorf("Expected second entry number to be 2, got %d", entries[1].Number)
}
}
func TestParseSRTTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
}{
{
name: "Standard format",
input: "00:00:01,000",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
{
name: "With milliseconds",
input: "00:00:01,500",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
},
{
name: "Full hours, minutes, seconds",
input: "01:02:03,456",
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456},
},
{
name: "With dot instead of comma",
input: "00:00:01.000", // Should auto-convert . to ,
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
{
name: "Invalid format",
input: "invalid",
expected: model.Timestamp{}, // Should return zero timestamp
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := parseSRTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
}
})
}
}
func TestFormatSRTTimestamp(t *testing.T) {
testCases := []struct {
name string
input model.Timestamp
expected string
}{
{
name: "Zero timestamp",
input: model.Timestamp{},
expected: "00:00:00,000",
},
{
name: "Simple seconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
expected: "00:00:01,000",
},
{
name: "With milliseconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
expected: "00:00:01,500",
},
{
name: "Full timestamp",
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456},
expected: "01:02:03,456",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := formatSRTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
}
})
}
}
func TestIsEntryTimeStampUnset(t *testing.T) {
testCases := []struct {
name string
entry model.SRTEntry
expected bool
}{
{
name: "Unset timestamp",
entry: model.SRTEntry{Number: 1},
expected: true,
},
{
name: "Set timestamp",
entry: model.SRTEntry{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := isEntryTimeStampUnset(tc.entry)
if result != tc.expected {
t.Errorf("For entry %+v, expected isEntryTimeStampUnset to be %v, got %v", tc.entry, tc.expected, result)
}
})
}
}
func TestConvertToLyrics(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is the first line.",
},
{
Number: 2,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
Content: "This is the second line.",
},
}
// Convert to lyrics
lyrics := ConvertToLyrics(entries)
// Check result
if len(lyrics.Timeline) != 2 {
t.Errorf("Expected 2 timeline entries, got %d", len(lyrics.Timeline))
}
if len(lyrics.Content) != 2 {
t.Errorf("Expected 2 content entries, got %d", len(lyrics.Content))
}
// Check timeline entries
if lyrics.Timeline[0] != entries[0].StartTime {
t.Errorf("First timeline entry: expected %+v, got %+v", entries[0].StartTime, lyrics.Timeline[0])
}
if lyrics.Timeline[1] != entries[1].StartTime {
t.Errorf("Second timeline entry: expected %+v, got %+v", entries[1].StartTime, lyrics.Timeline[1])
}
// Check content entries
if lyrics.Content[0] != entries[0].Content {
t.Errorf("First content entry: expected '%s', got '%s'", entries[0].Content, lyrics.Content[0])
}
if lyrics.Content[1] != entries[1].Content {
t.Errorf("Second content entry: expected '%s', got '%s'", entries[1].Content, lyrics.Content[1])
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.srt")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
entries, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Parse failed on empty file: %v", err)
}
if len(entries) != 0 {
t.Errorf("Expected 0 entries for empty file, got %d", len(entries))
}
// Test with malformed file (missing timestamp line)
malformedFile := filepath.Join(tempDir, "malformed.srt")
content := `1
This is missing a timestamp line.
2
00:00:05,000 --> 00:00:08,000
This is valid.
`
if err := os.WriteFile(malformedFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create malformed test file: %v", err)
}
entries, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Parse failed on malformed file: %v", err)
}
// SRT解析器更宽容可能会解析出两个条目
if len(entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(entries))
}
// Test with incomplete last entry
incompleteFile := filepath.Join(tempDir, "incomplete.srt")
content = `1
00:00:01,000 --> 00:00:04,000
This is complete.
2
00:00:05,000 --> 00:00:08,000
`
if err := os.WriteFile(incompleteFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create incomplete test file: %v", err)
}
entries, err = Parse(incompleteFile)
if err != nil {
t.Fatalf("Parse failed on incomplete file: %v", err)
}
// Should have one complete entry, the incomplete one is discarded due to empty content
if len(entries) != 1 {
t.Errorf("Expected 1 entry (only the completed one), got %d", len(entries))
}
}
func TestParse_FileError(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test entries
entries := []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
Content: "This is a test line.",
},
}
// Test with invalid path
err := Generate(entries, "/nonexistent/directory/file.srt")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}
func TestFormat_FileError(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}
func TestConvertToSubtitle_WithHTMLTags(t *testing.T) {
// Create a temporary test file with HTML tags
content := `1
00:00:01,000 --> 00:00:04,000
<i>This is in italic.</i>
2
00:00:05,000 --> 00:00:08,000
<b>This is in bold.</b>
3
00:00:09,000 --> 00:00:12,000
<u>This is underlined.</u>
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "styles.srt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file with HTML tags: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check if HTML tags were detected
if value, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok || value != true {
t.Errorf("Expected FormatData to contain has_html_tags=true for entry with italic")
}
if value, ok := subtitle.Entries[0].Styles["italic"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain italic=true for entry with <i> tag")
}
if value, ok := subtitle.Entries[1].Styles["bold"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain bold=true for entry with <b> tag")
}
if value, ok := subtitle.Entries[2].Styles["underline"]; !ok || value != "true" {
t.Errorf("Expected Styles to contain underline=true for entry with <u> tag")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.srt")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertFromSubtitle_WithStyling(t *testing.T) {
// Create a subtitle with style attributes
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Create an entry with italics
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This should be italic."
entry1.Styles["italic"] = "true"
// Create an entry with bold
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This should be bold."
entry2.Styles["bold"] = "true"
// Create an entry with underline
entry3 := model.NewSubtitleEntry()
entry3.Index = 3
entry3.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0}
entry3.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0}
entry3.Text = "This should be underlined."
entry3.Styles["underline"] = "true"
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "styled.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check that HTML tags were applied
contentStr := string(content)
if !strings.Contains(contentStr, "<i>This should be italic.</i>") {
t.Errorf("Expected italic HTML tags to be applied")
}
if !strings.Contains(contentStr, "<b>This should be bold.</b>") {
t.Errorf("Expected bold HTML tags to be applied")
}
if !strings.Contains(contentStr, "<u>This should be underlined.</u>") {
t.Errorf("Expected underline HTML tags to be applied")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.srt")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}
func TestConvertFromSubtitle_WithExistingHTMLTags(t *testing.T) {
// Create a subtitle with text that already contains HTML tags
subtitle := model.NewSubtitle()
subtitle.Format = "srt"
// Create an entry with existing italic tags but also style attribute
entry := model.NewSubtitleEntry()
entry.Index = 1
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry.Text = "<i>Already italic text.</i>"
entry.Styles["italic"] = "true" // Should not double-wrap with <i> tags
subtitle.Entries = append(subtitle.Entries, entry)
// Convert from subtitle to SRT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "existing_tags.srt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Should not have double tags
contentStr := string(content)
if strings.Contains(contentStr, "<i><i>") {
t.Errorf("Expected no duplicate italic tags, but found them")
}
}

View file

@ -0,0 +1,182 @@
package srt
import (
"testing"
"sub-cli/internal/model"
)
func TestParseSRTTimestamp(t *testing.T) {
testCases := []struct {
input string
expected model.Timestamp
}{
{
input: "00:00:01,000",
expected: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 1,
Milliseconds: 0,
},
},
{
input: "01:02:03,456",
expected: model.Timestamp{
Hours: 1,
Minutes: 2,
Seconds: 3,
Milliseconds: 456,
},
},
{
input: "10:20:30,789",
expected: model.Timestamp{
Hours: 10,
Minutes: 20,
Seconds: 30,
Milliseconds: 789,
},
},
{
// Test invalid format
input: "invalid",
expected: model.Timestamp{},
},
{
// Test with dot instead of comma
input: "01:02:03.456",
expected: model.Timestamp{
Hours: 1,
Minutes: 2,
Seconds: 3,
Milliseconds: 456,
},
},
}
for _, tc := range testCases {
result := parseSRTTimestamp(tc.input)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("parseSRTTimestamp(%s) = %+v, want %+v",
tc.input, result, tc.expected)
}
}
}
func TestFormatSRTTimestamp(t *testing.T) {
testCases := []struct {
input model.Timestamp
expected string
}{
{
input: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 1,
Milliseconds: 0,
},
expected: "00:00:01,000",
},
{
input: model.Timestamp{
Hours: 1,
Minutes: 2,
Seconds: 3,
Milliseconds: 456,
},
expected: "01:02:03,456",
},
{
input: model.Timestamp{
Hours: 10,
Minutes: 20,
Seconds: 30,
Milliseconds: 789,
},
expected: "10:20:30,789",
},
}
for _, tc := range testCases {
result := formatSRTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("formatSRTTimestamp(%+v) = %s, want %s",
tc.input, result, tc.expected)
}
}
}
func TestIsEntryTimeStampUnset(t *testing.T) {
testCases := []struct {
entry model.SRTEntry
expected bool
}{
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
},
},
expected: true,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 1,
Milliseconds: 0,
},
},
expected: false,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 1,
Seconds: 0,
Milliseconds: 0,
},
},
expected: false,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 1,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
},
},
expected: false,
},
{
entry: model.SRTEntry{
StartTime: model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 1,
},
},
expected: false,
},
}
for i, tc := range testCases {
result := isEntryTimeStampUnset(tc.entry)
if result != tc.expected {
t.Errorf("Case %d: isEntryTimeStampUnset(%+v) = %v, want %v",
i, tc.entry, result, tc.expected)
}
}
}

View file

@ -0,0 +1,179 @@
package vtt
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `WEBVTT - Test Title
STYLE
::cue {
color: white;
}
NOTE This is a test comment
1
00:00:01.000 --> 00:00:04.000 align:start position:10%
This is <i>styled</i> text.
2
00:00:05.000 --> 00:00:08.000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
}
if subtitle.Title != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
}
// Check style conversion
if _, ok := subtitle.Styles["css"]; !ok {
t.Errorf("Expected CSS style to be preserved in subtitle.Styles['css'], got: %v", subtitle.Styles)
}
// Check entry count and content
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
// The VTT parser does not strip HTML tags by default
if subtitle.Entries[0].Text != "This is <i>styled</i> text." {
t.Errorf("First entry text: expected 'This is <i>styled</i> text.', got '%s'", subtitle.Entries[0].Text)
}
if subtitle.Entries[0].Styles["align"] != "start" {
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
}
// 检查 FormatData 中是否记录了 HTML 标签存在
if val, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok || val != true {
t.Errorf("Expected FormatData['has_html_tags'] to be true for entry with HTML tags")
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create a subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Title = "Test VTT"
subtitle.Styles = map[string]string{"css": "::cue { color: white; }"}
subtitle.Comments = append(subtitle.Comments, "This is a test comment")
// Create a region
region := model.NewSubtitleRegion("region1")
region.Settings["width"] = "40%"
region.Settings["lines"] = "3"
subtitle.Regions = append(subtitle.Regions, region)
// Create entries
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry1.Styles["region"] = "region1"
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is <i>italic</i> text."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert to VTT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.vtt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by reading the file directly
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Check header
if !strings.HasPrefix(contentStr, "WEBVTT - Test VTT") {
t.Errorf("Expected header with title in output")
}
// Check style section
if !strings.Contains(contentStr, "STYLE") {
t.Errorf("Expected STYLE section in output")
}
if !strings.Contains(contentStr, "::cue { color: white; }") {
t.Errorf("Expected CSS content in style section")
}
// Check comment
if !strings.Contains(contentStr, "NOTE This is a test comment") {
t.Errorf("Expected comment in output")
}
// Check region
if !strings.Contains(contentStr, "REGION") || !strings.Contains(contentStr, "region1") {
t.Errorf("Expected region definition in output")
}
// Check region applied to first entry
if !strings.Contains(contentStr, "region:region1") {
t.Errorf("Expected region style to be applied to first entry")
}
// Check HTML tags
if !strings.Contains(contentStr, "<i>") || !strings.Contains(contentStr, "</i>") {
t.Errorf("Expected HTML italic tags in second entry")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test with non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when converting non-existent file, got nil")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Create simple subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
// Test with invalid path
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.vtt")
if err == nil {
t.Error("Expected error when converting to invalid path, got nil")
}
}

View file

@ -0,0 +1,78 @@
package vtt
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a temporary test file with valid VTT content
// 注意格式必须严格符合 WebVTT 规范,否则 Parse 会失败
content := `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first line.
2
00:00:05.000 --> 00:00:08.000 align:center
This is the second line.
3
00:00:09.500 --> 00:00:12.800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formatted, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
// 检查基本的内容是否存在
formattedStr := string(formatted)
// 检查标题行
if !strings.Contains(formattedStr, "WEBVTT") {
t.Errorf("Expected WEBVTT header in output, not found")
}
// 检查内容是否保留
if !strings.Contains(formattedStr, "This is the first line.") {
t.Errorf("Expected 'This is the first line.' in output, not found")
}
if !strings.Contains(formattedStr, "This is the second line.") {
t.Errorf("Expected 'This is the second line.' in output, not found")
}
if !strings.Contains(formattedStr, "This is the third line") {
t.Errorf("Expected 'This is the third line' in output, not found")
}
if !strings.Contains(formattedStr, "with a line break.") {
t.Errorf("Expected 'with a line break.' in output, not found")
}
}
func TestFormat_FileErrors(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}

View file

@ -0,0 +1,148 @@
package vtt
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create a test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Title = "Test VTT"
// Add style section
subtitle.Styles = map[string]string{"css": "::cue { color: white; }"}
// Add comments
subtitle.Comments = append(subtitle.Comments, "This is a test comment")
// Create entries
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
entry2.Styles = map[string]string{"align": "center"}
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Generate VTT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.vtt")
err := Generate(subtitle, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
contentStr := string(content)
// Verify header
if !strings.HasPrefix(contentStr, "WEBVTT - Test VTT") {
t.Errorf("Expected header with title, got: %s", strings.Split(contentStr, "\n")[0])
}
// Verify style section
if !strings.Contains(contentStr, "STYLE") {
t.Errorf("Expected STYLE section in output")
}
if !strings.Contains(contentStr, "::cue { color: white; }") {
t.Errorf("Expected CSS content in style section")
}
// Verify comment
if !strings.Contains(contentStr, "NOTE This is a test comment") {
t.Errorf("Expected comment in output")
}
// Verify first entry
if !strings.Contains(contentStr, "00:00:01.000 --> 00:00:04.000") {
t.Errorf("Expected first entry timestamp in output")
}
if !strings.Contains(contentStr, "This is the first line.") {
t.Errorf("Expected first entry text in output")
}
// Verify second entry with style
if !strings.Contains(contentStr, "00:00:05.000 --> 00:00:08.000 align:center") {
t.Errorf("Expected second entry timestamp with align style in output")
}
}
func TestGenerate_WithRegions(t *testing.T) {
// Create a subtitle with regions
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Add a region
region := model.NewSubtitleRegion("region1")
region.Settings["width"] = "40%"
region.Settings["lines"] = "3"
region.Settings["regionanchor"] = "0%,100%"
subtitle.Regions = append(subtitle.Regions, region)
// Add an entry using the region
entry := model.NewSubtitleEntry()
entry.Index = 1
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry.Text = "This is a regional cue."
entry.Styles = map[string]string{"region": "region1"}
subtitle.Entries = append(subtitle.Entries, entry)
// Generate VTT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "regions.vtt")
err := Generate(subtitle, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify by reading file content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check if region is included
if !strings.Contains(string(content), "REGION region1:") {
t.Errorf("Expected REGION definition in output")
}
for k, v := range region.Settings {
if !strings.Contains(string(content), fmt.Sprintf("%s=%s", k, v)) {
t.Errorf("Expected region setting '%s=%s' in output", k, v)
}
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Test with invalid path
err := Generate(subtitle, "/nonexistent/directory/file.vtt")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}

View file

@ -0,0 +1,215 @@
package vtt
import (
"os"
"path/filepath"
"testing"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first line.
2
00:00:05.000 --> 00:00:08.000
This is the second line.
3
00:00:09.500 --> 00:00:12.800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if subtitle.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
if subtitle.Entries[0].StartTime.Hours != 0 || subtitle.Entries[0].StartTime.Minutes != 0 ||
subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:00:01.000, got %+v", subtitle.Entries[0].StartTime)
}
if subtitle.Entries[0].EndTime.Hours != 0 || subtitle.Entries[0].EndTime.Minutes != 0 ||
subtitle.Entries[0].EndTime.Seconds != 4 || subtitle.Entries[0].EndTime.Milliseconds != 0 {
t.Errorf("First entry end time: expected 00:00:04.000, got %+v", subtitle.Entries[0].EndTime)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
// Check third entry with line break
if subtitle.Entries[2].Index != 3 {
t.Errorf("Third entry index: expected 3, got %d", subtitle.Entries[2].Index)
}
expectedText := "This is the third line\nwith a line break."
if subtitle.Entries[2].Text != expectedText {
t.Errorf("Third entry text: expected '%s', got '%s'", expectedText, subtitle.Entries[2].Text)
}
}
func TestParse_WithHeader(t *testing.T) {
// Create a temporary test file with title
content := `WEBVTT - Test Title
1
00:00:01.000 --> 00:00:04.000
This is the first line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify title was extracted
if subtitle.Title != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
}
}
func TestParse_WithStyles(t *testing.T) {
// Create a temporary test file with CSS styling
content := `WEBVTT
STYLE
::cue {
color: white;
background-color: black;
}
1
00:00:01.000 --> 00:00:04.000 align:start position:10%
This is <b>styled</b> text.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// First check if we have entries at all
if len(subtitle.Entries) == 0 {
t.Fatalf("No entries found in parsed subtitle")
}
// Verify styling was captured
if subtitle.Entries[0].Styles == nil {
t.Fatalf("Entry styles map is nil")
}
// Verify HTML tags were detected
if _, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok {
t.Errorf("Expected HTML tags to be detected in entry")
}
// Verify cue settings were captured
if subtitle.Entries[0].Styles["align"] != "start" {
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
}
if subtitle.Entries[0].Styles["position"] != "10%" {
t.Errorf("Expected position style '10%%', got '%s'", subtitle.Entries[0].Styles["position"])
}
}
func TestParse_WithComments(t *testing.T) {
// Create a temporary test file with comments
content := `WEBVTT
NOTE This is a comment
NOTE This is another comment
1
00:00:01.000 --> 00:00:04.000
This is the first line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test_comments.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify comments were captured
if len(subtitle.Comments) != 2 {
t.Errorf("Expected 2 comments, got %d", len(subtitle.Comments))
}
if subtitle.Comments[0] != "This is a comment" {
t.Errorf("Expected first comment 'This is a comment', got '%s'", subtitle.Comments[0])
}
if subtitle.Comments[1] != "This is another comment" {
t.Errorf("Expected second comment 'This is another comment', got '%s'", subtitle.Comments[1])
}
}
func TestParse_FileErrors(t *testing.T) {
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.vtt")
if err := os.WriteFile(emptyFile, []byte(""), 0644); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
_, err := Parse(emptyFile)
if err == nil {
t.Error("Expected error when parsing empty file, got nil")
}
// Test with invalid WEBVTT header
invalidFile := filepath.Join(tempDir, "invalid.vtt")
if err := os.WriteFile(invalidFile, []byte("INVALID HEADER\n\n"), 0644); err != nil {
t.Fatalf("Failed to create invalid file: %v", err)
}
_, err = Parse(invalidFile)
if err == nil {
t.Error("Expected error when parsing file with invalid header, got nil")
}
// Test with non-existent file
_, err = Parse("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
}

View file

@ -0,0 +1,39 @@
package vtt
import (
"fmt"
"testing"
"sub-cli/internal/model"
)
func TestParseVTTTimestamp(t *testing.T) {
testCases := []struct {
input string
expected model.Timestamp
}{
// Standard format
{"00:00:01.000", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
// Without leading zeros
{"0:0:1.0", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
// Different millisecond formats
{"00:00:01.1", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 100}},
{"00:00:01.12", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 120}},
{"00:00:01.123", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
// Long milliseconds (should truncate)
{"00:00:01.1234", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
// Unusual but valid format
{"01:02:03.456", model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456}},
// Invalid format (should return a zero timestamp)
{"invalid", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0}},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Timestamp_%s", tc.input), func(t *testing.T) {
result := parseVTTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("parseVTTTimestamp(%s) = %+v, want %+v", tc.input, result, tc.expected)
}
})
}
}

View file

@ -1,507 +0,0 @@
package vtt
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create a temporary test file
content := `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first line.
2
00:00:05.000 --> 00:00:08.000
This is the second line.
3
00:00:09.500 --> 00:00:12.800
This is the third line
with a line break.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
if subtitle.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Index != 1 {
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
}
if subtitle.Entries[0].StartTime.Hours != 0 || subtitle.Entries[0].StartTime.Minutes != 0 ||
subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].StartTime.Milliseconds != 0 {
t.Errorf("First entry start time: expected 00:00:01.000, got %+v", subtitle.Entries[0].StartTime)
}
if subtitle.Entries[0].EndTime.Hours != 0 || subtitle.Entries[0].EndTime.Minutes != 0 ||
subtitle.Entries[0].EndTime.Seconds != 4 || subtitle.Entries[0].EndTime.Milliseconds != 0 {
t.Errorf("First entry end time: expected 00:00:04.000, got %+v", subtitle.Entries[0].EndTime)
}
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
// Check third entry with line break
if subtitle.Entries[2].Index != 3 {
t.Errorf("Third entry index: expected 3, got %d", subtitle.Entries[2].Index)
}
expectedText := "This is the third line\nwith a line break."
if subtitle.Entries[2].Text != expectedText {
t.Errorf("Third entry text: expected '%s', got '%s'", expectedText, subtitle.Entries[2].Text)
}
}
func TestParse_WithHeader(t *testing.T) {
// Create a temporary test file with title
content := `WEBVTT - Test Title
1
00:00:01.000 --> 00:00:04.000
This is the first line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify title was extracted
if subtitle.Title != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
}
}
func TestParse_WithStyles(t *testing.T) {
// Create a temporary test file with CSS styling
content := `WEBVTT
STYLE
::cue {
color: white;
background-color: black;
}
1
00:00:01.000 --> 00:00:04.000 align:start position:10%
This is <b>styled</b> text.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// First check if we have entries at all
if len(subtitle.Entries) == 0 {
t.Fatalf("No entries found in parsed subtitle")
}
// Verify styling was captured
if subtitle.Entries[0].Styles == nil {
t.Fatalf("Entry styles map is nil")
}
// Verify HTML tags were detected
if _, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok {
t.Errorf("Expected HTML tags to be detected in entry")
}
// Verify cue settings were captured
if subtitle.Entries[0].Styles["align"] != "start" {
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
}
if subtitle.Entries[0].Styles["position"] != "10%" {
t.Errorf("Expected position style '10%%', got '%s'", subtitle.Entries[0].Styles["position"])
}
}
func TestGenerate(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Title = "Test VTT"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
entry2.Styles["align"] = "center"
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Generate VTT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.vtt")
err := Generate(subtitle, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check content
lines := strings.Split(string(content), "\n")
if len(lines) < 9 { // Header + title + blank + cue1 (4 lines) + cue2 (4 lines with style)
t.Fatalf("Expected at least 9 lines, got %d", len(lines))
}
// Check header
if !strings.HasPrefix(lines[0], "WEBVTT") {
t.Errorf("Expected first line to start with WEBVTT, got '%s'", lines[0])
}
// Check title
if !strings.Contains(lines[0], "Test VTT") {
t.Errorf("Expected header to contain title 'Test VTT', got '%s'", lines[0])
}
// Parse the generated file to fully validate
parsedSubtitle, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse generated file: %v", err)
}
if len(parsedSubtitle.Entries) != 2 {
t.Errorf("Expected 2 entries in parsed output, got %d", len(parsedSubtitle.Entries))
}
// Check style preservation
if parsedSubtitle.Entries[1].Styles["align"] != "center" {
t.Errorf("Expected align style 'center', got '%s'", parsedSubtitle.Entries[1].Styles["align"])
}
}
func TestConvertToSubtitle(t *testing.T) {
// Create a temporary test file
content := `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is the first line.
2
00:00:05.000 --> 00:00:08.000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Check result
if subtitle.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
}
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
}
// Check first entry
if subtitle.Entries[0].Text != "This is the first line." {
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
subtitle.Title = "Test VTT"
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry1.Text = "This is the first line."
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
entry2.Text = "This is the second line."
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert from subtitle to VTT
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.vtt")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Verify by parsing back
parsedSubtitle, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse output file: %v", err)
}
if len(parsedSubtitle.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(parsedSubtitle.Entries))
}
if parsedSubtitle.Entries[0].Text != "This is the first line." {
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", parsedSubtitle.Entries[0].Text)
}
if parsedSubtitle.Title != "Test VTT" {
t.Errorf("Expected title 'Test VTT', got '%s'", parsedSubtitle.Title)
}
}
func TestFormat(t *testing.T) {
// Create test file with non-sequential identifiers
content := `WEBVTT
5
00:00:01.000 --> 00:00:04.000
This is the first line.
10
00:00:05.000 --> 00:00:08.000
This is the second line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Format the file
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Verify by parsing back
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Failed to parse formatted file: %v", err)
}
// Check that identifiers are sequential
if subtitle.Entries[0].Index != 1 {
t.Errorf("Expected first entry index to be 1, got %d", subtitle.Entries[0].Index)
}
if subtitle.Entries[1].Index != 2 {
t.Errorf("Expected second entry index to be 2, got %d", subtitle.Entries[1].Index)
}
}
func TestParse_FileErrors(t *testing.T) {
// Test with non-existent file
_, err := Parse("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when parsing non-existent file, got nil")
}
// Test with empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.vtt")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
_, err = Parse(emptyFile)
if err == nil {
t.Error("Expected error when parsing empty file, got nil")
}
// Test with invalid header
invalidFile := filepath.Join(tempDir, "invalid.vtt")
if err := os.WriteFile(invalidFile, []byte("NOT A WEBVTT FILE\n\n"), 0644); err != nil {
t.Fatalf("Failed to create invalid test file: %v", err)
}
_, err = Parse(invalidFile)
if err == nil {
t.Error("Expected error when parsing file with invalid header, got nil")
}
}
func TestParseVTTTimestamp(t *testing.T) {
testCases := []struct {
input string
expected model.Timestamp
}{
// Standard format
{"00:00:01.000", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
// Without leading zeros
{"0:0:1.0", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
// Different millisecond formats
{"00:00:01.1", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 100}},
{"00:00:01.12", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 120}},
{"00:00:01.123", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
// Long milliseconds (should truncate)
{"00:00:01.1234", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
// Unusual but valid format
{"01:02:03.456", model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456}},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Timestamp_%s", tc.input), func(t *testing.T) {
result := parseVTTTimestamp(tc.input)
if result != tc.expected {
t.Errorf("parseVTTTimestamp(%s) = %+v, want %+v", tc.input, result, tc.expected)
}
})
}
}
func TestParse_WithComments(t *testing.T) {
// Create a temporary test file with comments
content := `WEBVTT
NOTE This is a comment
NOTE This is another comment
1
00:00:01.000 --> 00:00:04.000
This is the first line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test_comments.vtt")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
subtitle, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify comments were captured
if len(subtitle.Comments) != 2 {
t.Errorf("Expected 2 comments, got %d", len(subtitle.Comments))
}
if subtitle.Comments[0] != "This is a comment" {
t.Errorf("Expected first comment 'This is a comment', got '%s'", subtitle.Comments[0])
}
if subtitle.Comments[1] != "This is another comment" {
t.Errorf("Expected second comment 'This is another comment', got '%s'", subtitle.Comments[1])
}
}
func TestGenerate_WithRegions(t *testing.T) {
// Create a subtitle with regions
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Add a region
region := model.NewSubtitleRegion("region1")
region.Settings["width"] = "40%"
region.Settings["lines"] = "3"
region.Settings["regionanchor"] = "0%,100%"
subtitle.Regions = append(subtitle.Regions, region)
// Add an entry using the region
entry := model.NewSubtitleEntry()
entry.Index = 1
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
entry.Text = "This is a regional cue."
entry.Styles["region"] = "region1"
subtitle.Entries = append(subtitle.Entries, entry)
// Generate VTT file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "regions.vtt")
err := Generate(subtitle, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify by reading file content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// Check if region is included
if !strings.Contains(string(content), "REGION region1:") {
t.Errorf("Expected REGION definition in output")
}
for k, v := range region.Settings {
if !strings.Contains(string(content), fmt.Sprintf("%s=%s", k, v)) {
t.Errorf("Expected region setting '%s=%s' in output", k, v)
}
}
}
func TestFormat_FileErrors(t *testing.T) {
// Test with non-existent file
err := Format("/nonexistent/file.vtt")
if err == nil {
t.Error("Expected error when formatting non-existent file, got nil")
}
}
func TestGenerate_FileError(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "vtt"
// Test with invalid path
err := Generate(subtitle, "/nonexistent/directory/file.vtt")
if err == nil {
t.Error("Expected error when generating to invalid path, got nil")
}
}

View file

@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sub-cli/internal/format/ass"
"sub-cli/internal/format/lrc" "sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt" "sub-cli/internal/format/srt"
"sub-cli/internal/format/vtt" "sub-cli/internal/format/vtt"
@ -21,6 +22,8 @@ func Format(filePath string) error {
return srt.Format(filePath) return srt.Format(filePath)
case "vtt": case "vtt":
return vtt.Format(filePath) return vtt.Format(filePath)
case "ass":
return ass.Format(filePath)
default: default:
return fmt.Errorf("unsupported format for formatting: %s", ext) return fmt.Errorf("unsupported format for formatting: %s", ext)
} }

View file

@ -53,6 +53,34 @@ type SubtitleRegion struct {
Settings map[string]string Settings map[string]string
} }
// ASSEvent represents an event entry in an ASS file (dialogue, comment, etc.)
type ASSEvent struct {
Type string // Dialogue, Comment, etc.
Layer int // Layer number (0-based)
StartTime Timestamp // Start time
EndTime Timestamp // End time
Style string // Style name
Name string // Character name
MarginL int // Left margin override
MarginR int // Right margin override
MarginV int // Vertical margin override
Effect string // Transition effect
Text string // The actual text
}
// ASSStyle represents a style definition in an ASS file
type ASSStyle struct {
Name string // Style name
Properties map[string]string // Font name, size, colors, etc.
}
// ASSFile represents an Advanced SubStation Alpha (ASS) file
type ASSFile struct {
ScriptInfo map[string]string // Format, Title, ScriptType, etc.
Styles []ASSStyle // Style definitions
Events []ASSEvent // Dialogue lines
}
// Creates a new empty Subtitle // Creates a new empty Subtitle
func NewSubtitle() Subtitle { func NewSubtitle() Subtitle {
return Subtitle{ return Subtitle{
@ -82,3 +110,42 @@ func NewSubtitleRegion(id string) SubtitleRegion {
Settings: make(map[string]string), Settings: make(map[string]string),
} }
} }
// NewASSFile creates a new empty ASS file structure with minimal defaults
func NewASSFile() ASSFile {
// Create minimal defaults for a valid ASS file
scriptInfo := map[string]string{
"ScriptType": "v4.00+",
"Collisions": "Normal",
"PlayResX": "640",
"PlayResY": "480",
"Timer": "100.0000",
}
// Create a default style
defaultStyle := ASSStyle{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
return ASSFile{
ScriptInfo: scriptInfo,
Styles: []ASSStyle{defaultStyle},
Events: []ASSEvent{},
}
}
// NewASSEvent creates a new ASS event with default values
func NewASSEvent() ASSEvent {
return ASSEvent{
Type: "Dialogue",
Layer: 0,
Style: "Default",
MarginL: 0,
MarginR: 0,
MarginV: 0,
}
}

View file

@ -2,6 +2,7 @@ package model
import ( import (
"testing" "testing"
"strings"
) )
func TestNewSubtitle(t *testing.T) { func TestNewSubtitle(t *testing.T) {
@ -98,3 +99,117 @@ func TestNewSubtitleRegion(t *testing.T) {
t.Errorf("Expected settings to contain lines=3, got %s", val) t.Errorf("Expected settings to contain lines=3, got %s", val)
} }
} }
func TestNewASSFile(t *testing.T) {
assFile := NewASSFile()
// Test that script info is initialized with defaults
if assFile.ScriptInfo == nil {
t.Error("Expected ScriptInfo map to be initialized")
}
// Check default script info values
expectedDefaults := map[string]string{
"ScriptType": "v4.00+",
"Collisions": "Normal",
"PlayResX": "640",
"PlayResY": "480",
"Timer": "100.0000",
}
for key, expectedValue := range expectedDefaults {
if value, exists := assFile.ScriptInfo[key]; !exists || value != expectedValue {
t.Errorf("Expected default ScriptInfo[%s] = %s, got %s", key, expectedValue, value)
}
}
// Test that styles are initialized
if assFile.Styles == nil {
t.Error("Expected Styles slice to be initialized")
}
// Test that at least the Default style exists
if len(assFile.Styles) < 1 {
t.Error("Expected at least Default style to be created")
} else {
defaultStyleFound := false
for _, style := range assFile.Styles {
if style.Name == "Default" {
defaultStyleFound = true
// Check the style properties of the default style
styleStr, exists := style.Properties["Style"]
if !exists {
t.Error("Expected Default style to have a Style property, but it wasn't found")
} else if !strings.Contains(styleStr, ",0,0,0,0,") { // Check that Bold, Italic, Underline, StrikeOut are all 0
t.Errorf("Expected Default style to have Bold/Italic/Underline/StrikeOut set to 0, got: %s", styleStr)
}
break
}
}
if !defaultStyleFound {
t.Error("Expected to find a Default style")
}
}
// Test that events are initialized as an empty slice
if assFile.Events == nil {
t.Error("Expected Events slice to be initialized")
}
if len(assFile.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(assFile.Events))
}
}
func TestNewASSEvent(t *testing.T) {
event := NewASSEvent()
// Test default type
if event.Type != "Dialogue" {
t.Errorf("Expected Type to be 'Dialogue', got '%s'", event.Type)
}
// Test default layer
if event.Layer != 0 {
t.Errorf("Expected Layer to be 0, got %d", event.Layer)
}
// Test default style
if event.Style != "Default" {
t.Errorf("Expected Style to be 'Default', got '%s'", event.Style)
}
// Test default name
if event.Name != "" {
t.Errorf("Expected Name to be empty, got '%s'", event.Name)
}
// Test default margins
if event.MarginL != 0 || event.MarginR != 0 || event.MarginV != 0 {
t.Errorf("Expected all margins to be 0, got L:%d, R:%d, V:%d",
event.MarginL, event.MarginR, event.MarginV)
}
// Test default effect
if event.Effect != "" {
t.Errorf("Expected Effect to be empty, got '%s'", event.Effect)
}
// Test default text
if event.Text != "" {
t.Errorf("Expected Text to be empty, got '%s'", event.Text)
}
// Test start and end times
zeroTime := Timestamp{}
if event.StartTime != zeroTime {
t.Errorf("Expected start time to be zero, got %+v", event.StartTime)
}
if event.EndTime != zeroTime {
t.Errorf("Expected end time to be zero, got %+v", event.EndTime)
}
}

80
internal/sync/ass.go Normal file
View file

@ -0,0 +1,80 @@
package sync
import (
"fmt"
"sub-cli/internal/format/ass"
"sub-cli/internal/model"
)
// syncASSFiles synchronizes two ASS files
func syncASSFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := ass.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source ASS file: %w", err)
}
targetSubtitle, err := ass.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target ASS file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Events), len(targetSubtitle.Events))
}
// Sync the timelines
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return ass.Generate(syncedSubtitle, targetFile)
}
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
result := model.ASSFile{
ScriptInfo: target.ScriptInfo,
Styles: target.Styles,
Events: make([]model.ASSEvent, len(target.Events)),
}
// Copy target events
copy(result.Events, target.Events)
// If there are no events in either source or target, return as is
if len(source.Events) == 0 || len(target.Events) == 0 {
return result
}
// Extract start and end timestamps from source
sourceStartTimes := make([]model.Timestamp, len(source.Events))
sourceEndTimes := make([]model.Timestamp, len(source.Events))
for i, event := range source.Events {
sourceStartTimes[i] = event.StartTime
sourceEndTimes[i] = event.EndTime
}
// Scale timestamps if source and target event counts differ
var scaledStartTimes, scaledEndTimes []model.Timestamp
if len(source.Events) == len(target.Events) {
// If counts match, use source times directly
scaledStartTimes = sourceStartTimes
scaledEndTimes = sourceEndTimes
} else {
// Scale the timelines to match target count
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
}
// Apply scaled timeline to target events
for i := range result.Events {
result.Events[i].StartTime = scaledStartTimes[i]
result.Events[i].EndTime = scaledEndTimes[i]
}
return result
}

465
internal/sync/ass_test.go Normal file
View file

@ -0,0 +1,465 @@
package sync
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestSyncASSTimeline(t *testing.T) {
testCases := []struct {
name string
source model.ASSFile
target model.ASSFile
verify func(t *testing.T, result model.ASSFile)
}{
{
name: "Equal event counts",
source: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Source ASS"},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Default,Arial,20,&H00FFFFFF",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Style: "Default",
Text: "Source line three.",
},
},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target ASS"},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Default,Arial,20,&H00FFFFFF",
},
},
{
Name: "Alternate",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour",
"Style": "Alternate,Times New Roman,20,&H0000FFFF",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Alternate",
Text: "Target line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Style: "Default",
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(result.Events))
return
}
// Check that source timings are applied to target events
if result.Events[0].StartTime.Seconds != 1 || result.Events[0].EndTime.Seconds != 4 {
t.Errorf("First event timing mismatch: got %+v", result.Events[0])
}
if result.Events[1].StartTime.Seconds != 5 || result.Events[1].EndTime.Seconds != 8 {
t.Errorf("Second event timing mismatch: got %+v", result.Events[1])
}
if result.Events[2].StartTime.Seconds != 9 || result.Events[2].EndTime.Seconds != 12 {
t.Errorf("Third event timing mismatch: got %+v", result.Events[2])
}
// Check that target content and styles are preserved
if result.Events[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
}
if result.Events[1].Style != "Alternate" {
t.Errorf("Style should be preserved, got: %s", result.Events[1].Style)
}
// Check that script info and style definitions are preserved
if result.ScriptInfo["Title"] != "Target ASS" {
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
}
if len(result.Styles) != 2 {
t.Errorf("Expected 2 styles, got %d", len(result.Styles))
}
if result.Styles[1].Name != "Alternate" {
t.Errorf("Style definitions should be preserved, got: %+v", result.Styles[1])
}
},
},
{
name: "More target events than source",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
},
},
target: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Style: "Default",
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(result.Events))
return
}
// First event should use first source timing
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
}
// Last event should use last source timing
if result.Events[2].StartTime.Seconds != 5 {
t.Errorf("Last event should have last source timing, got: %+v", result.Events[2].StartTime)
}
// Verify content is preserved
if result.Events[2].Text != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result.Events[2].Text)
}
},
},
{
name: "More source events than target",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Style: "Default",
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Style: "Default",
Text: "Source line three.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Style: "Default",
Text: "Source line four.",
},
},
},
target: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(result.Events))
return
}
// First event should have first source timing
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
}
// Last event should have last source timing
if result.Events[1].StartTime.Seconds != 10 {
t.Errorf("Last event should have last source timing, got: %+v", result.Events[1].StartTime)
}
// Check that target content is preserved
if result.Events[0].Text != "Target line one." || result.Events[1].Text != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Events)
}
},
},
{
name: "Empty target events",
source: model.ASSFile{
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Empty Target"},
Events: []model.ASSEvent{},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(result.Events))
}
// ScriptInfo should be preserved
if result.ScriptInfo["Title"] != "Empty Target" {
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
}
},
},
{
name: "Empty source events",
source: model.ASSFile{
Events: []model.ASSEvent{},
},
target: model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target with content"},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 15},
Style: "Default",
Text: "Target line one.",
},
},
},
verify: func(t *testing.T, result model.ASSFile) {
if len(result.Events) != 1 {
t.Errorf("Expected 1 event, got %d", len(result.Events))
return
}
// Timing should be preserved since source is empty
if result.Events[0].StartTime.Seconds != 10 || result.Events[0].EndTime.Seconds != 15 {
t.Errorf("Timing should match target when source is empty, got: %+v", result.Events[0])
}
// Content should be preserved
if result.Events[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
}
// Title should be preserved
if result.ScriptInfo["Title"] != "Target with content" {
t.Errorf("Title should be preserved, got: %+v", result.ScriptInfo)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncASSTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}
func TestSyncASSFiles(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Test case for testing the sync of ASS files
sourceContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Source ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
`
targetContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Timer: 100.0000
Title: Target ASS File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Alternate,Arial,20,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
Dialogue: 0,0:01:05.00,0:01:08.00,Alternate,,0,0,0,,Target line two.
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
`
sourceFile := filepath.Join(tempDir, "source.ass")
targetFile := filepath.Join(tempDir, "target.ass")
// Write test files
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to write source file: %v", err)
}
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to write target file: %v", err)
}
// Run syncASSFiles
err := syncASSFiles(sourceFile, targetFile)
if err != nil {
t.Fatalf("syncASSFiles returned error: %v", err)
}
// Read the modified target file
modifiedContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read modified file: %v", err)
}
// Verify the result
// Should have source timings
if !strings.Contains(string(modifiedContent), "0:00:01.00") {
t.Errorf("Output should have source timing 0:00:01.00, got: %s", string(modifiedContent))
}
// Should preserve target content and styles
if !strings.Contains(string(modifiedContent), "Target line one.") {
t.Errorf("Output should preserve target content, got: %s", string(modifiedContent))
}
if !strings.Contains(string(modifiedContent), "Style: Alternate") {
t.Errorf("Output should preserve target styles, got: %s", string(modifiedContent))
}
// Should preserve title
if !strings.Contains(string(modifiedContent), "Title: Target ASS File") {
t.Errorf("Output should preserve target title, got: %s", string(modifiedContent))
}
}

64
internal/sync/lrc.go Normal file
View file

@ -0,0 +1,64 @@
package sync
import (
"fmt"
"sub-cli/internal/format/lrc"
"sub-cli/internal/model"
)
// syncLRCFiles synchronizes two LRC files
func syncLRCFiles(sourceFile, targetFile string) error {
source, err := lrc.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source file: %w", err)
}
target, err := lrc.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target file: %w", err)
}
// Check if line counts match
if len(source.Timeline) != len(target.Content) {
fmt.Printf("Warning: Source timeline (%d entries) and target content (%d entries) have different counts. Timeline will be scaled.\n",
len(source.Timeline), len(target.Content))
}
// Apply timeline from source to target
syncedLyrics := syncLRCTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, targetFile)
}
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
result := model.Lyrics{
Metadata: target.Metadata,
Content: target.Content,
}
// If target has no content, return empty result with metadata only
if len(target.Content) == 0 {
result.Timeline = []model.Timestamp{}
return result
}
// If source has no timeline, keep target as is
if len(source.Timeline) == 0 {
result.Timeline = target.Timeline
return result
}
// Scale the source timeline to match the target content length
if len(source.Timeline) != len(target.Content) {
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
} else {
// If lengths match, directly use source timeline
result.Timeline = make([]model.Timestamp, len(source.Timeline))
copy(result.Timeline, source.Timeline)
}
return result
}

265
internal/sync/lrc_test.go Normal file
View file

@ -0,0 +1,265 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncLRCTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Lyrics
target model.Lyrics
verify func(t *testing.T, result model.Lyrics)
}{
{
name: "Equal content length",
source: model.Lyrics{
Metadata: map[string]string{
"ti": "Source LRC",
"ar": "Test Artist",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
{Minutes: 0, Seconds: 9, Milliseconds: 500},
},
Content: []string{
"This is line one.",
"This is line two.",
"This is line three.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
"ar": "Different Artist",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
{Minutes: 0, Seconds: 30, Milliseconds: 0},
},
Content: []string{
"This is line one with different timing.",
"This is line two with different timing.",
"This is line three with different timing.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are applied
if result.Timeline[0].Seconds != 1 || result.Timeline[0].Milliseconds != 0 {
t.Errorf("First timeline entry should have source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[1].Seconds != 5 || result.Timeline[1].Milliseconds != 0 {
t.Errorf("Second timeline entry should have source timing, got: %+v", result.Timeline[1])
}
if result.Timeline[2].Seconds != 9 || result.Timeline[2].Milliseconds != 500 {
t.Errorf("Third timeline entry should have source timing, got: %+v", result.Timeline[2])
}
// Verify that target content is preserved
if result.Content[0] != "This is line one with different timing." {
t.Errorf("Content should be preserved, got: %s", result.Content[0])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target LRC" || result.Metadata["ar"] != "Different Artist" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "More target content than source timeline",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
},
Content: []string{
"This is line one.",
"This is line two.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
{Minutes: 0, Seconds: 30, Milliseconds: 0},
},
Content: []string{
"This is line one with different timing.",
"This is line two with different timing.",
"This is line three with different timing.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 3 {
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are scaled
if result.Timeline[0].Seconds != 1 {
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[2].Seconds != 5 {
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[2])
}
// Verify that target content is preserved
if result.Content[2] != "This is line three with different timing." {
t.Errorf("Content should be preserved, got: %s", result.Content[2])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target LRC" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "More source timeline than target content",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
{Minutes: 0, Seconds: 3, Milliseconds: 0},
{Minutes: 0, Seconds: 5, Milliseconds: 0},
{Minutes: 0, Seconds: 7, Milliseconds: 0},
},
Content: []string{
"Source line one.",
"Source line two.",
"Source line three.",
"Source line four.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target LRC",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
{Minutes: 0, Seconds: 20, Milliseconds: 0},
},
Content: []string{
"Target line one.",
"Target line two.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 2 {
t.Errorf("Expected 2 timeline entries, got %d", len(result.Timeline))
return
}
// Verify that source timings are scaled
if result.Timeline[0].Seconds != 1 {
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
}
if result.Timeline[1].Seconds != 7 {
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[1])
}
// Verify that target content is preserved
if result.Content[0] != "Target line one." || result.Content[1] != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Content)
}
},
},
{
name: "Empty target content",
source: model.Lyrics{
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 1, Milliseconds: 0},
},
Content: []string{
"Source line one.",
},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Empty Target",
},
Timeline: []model.Timestamp{},
Content: []string{},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 0 {
t.Errorf("Expected 0 timeline entries, got %d", len(result.Timeline))
}
if len(result.Content) != 0 {
t.Errorf("Expected 0 content entries, got %d", len(result.Content))
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Empty Target" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
{
name: "Empty source timeline",
source: model.Lyrics{
Timeline: []model.Timestamp{},
Content: []string{},
},
target: model.Lyrics{
Metadata: map[string]string{
"ti": "Target with content",
},
Timeline: []model.Timestamp{
{Minutes: 0, Seconds: 10, Milliseconds: 0},
},
Content: []string{
"Target line one.",
},
},
verify: func(t *testing.T, result model.Lyrics) {
if len(result.Timeline) != 1 {
t.Errorf("Expected 1 timeline entry, got %d", len(result.Timeline))
return
}
// Verify that target timing is preserved when source is empty
if result.Timeline[0].Seconds != 10 {
t.Errorf("Timeline should match target when source is empty, got: %+v", result.Timeline[0])
}
// Verify that target content is preserved
if result.Content[0] != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Content[0])
}
// Verify that target metadata is preserved
if result.Metadata["ti"] != "Target with content" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncLRCTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

100
internal/sync/srt.go Normal file
View file

@ -0,0 +1,100 @@
package sync
import (
"fmt"
"sub-cli/internal/format/srt"
"sub-cli/internal/model"
)
// syncSRTFiles synchronizes two SRT files
func syncSRTFiles(sourceFile, targetFile string) error {
sourceEntries, err := srt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source SRT file: %w", err)
}
targetEntries, err := srt.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target SRT file: %w", err)
}
// Check if entry counts match
if len(sourceEntries) != len(targetEntries) {
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
len(sourceEntries), len(targetEntries))
}
// Sync the timelines
syncedEntries := syncSRTTimeline(sourceEntries, targetEntries)
// Write the synced entries to the target file
return srt.Generate(syncedEntries, targetFile)
}
// syncSRTTimeline applies the timing from source SRT entries to target SRT entries
func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTEntry {
result := make([]model.SRTEntry, len(targetEntries))
// Copy target entries
copy(result, targetEntries)
// If source is empty, just return the target entries as is
if len(sourceEntries) == 0 {
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}
// If source and target have the same number of entries, directly apply timings
if len(sourceEntries) == len(targetEntries) {
for i := range result {
result[i].StartTime = sourceEntries[i].StartTime
result[i].EndTime = sourceEntries[i].EndTime
}
} else {
// If entry counts differ, scale the timing
for i := range result {
// Calculate scaled index
sourceIdx := 0
if len(sourceEntries) > 1 {
sourceIdx = i * (len(sourceEntries) - 1) / (len(targetEntries) - 1)
}
// Ensure the index is within bounds
if sourceIdx >= len(sourceEntries) {
sourceIdx = len(sourceEntries) - 1
}
// Apply the scaled timing
result[i].StartTime = sourceEntries[sourceIdx].StartTime
// Calculate end time: if not the last entry, use duration from source
if i < len(result)-1 {
// If next source entry exists, calculate duration
var duration model.Timestamp
if sourceIdx+1 < len(sourceEntries) {
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx+1].StartTime)
} else {
// If no next source entry, use the source's end time (usually a few seconds after start)
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx].EndTime)
}
// Apply duration to next start time
result[i].EndTime = addDuration(result[i].StartTime, duration)
} else {
// For the last entry, add a fixed duration (e.g., 3 seconds)
result[i].EndTime = sourceEntries[sourceIdx].EndTime
}
}
}
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}

274
internal/sync/srt_test.go Normal file
View file

@ -0,0 +1,274 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncSRTTimeline(t *testing.T) {
testCases := []struct {
name string
sourceEntries []model.SRTEntry
targetEntries []model.SRTEntry
verify func(t *testing.T, result []model.SRTEntry)
}{
{
name: "Equal entry counts",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line three.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Content: "Target line three.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result))
return
}
// Check that source timings are applied to target entries
if result[0].StartTime.Seconds != 1 || result[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v", result[0])
}
if result[1].StartTime.Seconds != 5 || result[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing mismatch: got %+v", result[1])
}
if result[2].StartTime.Seconds != 9 || result[2].EndTime.Seconds != 12 {
t.Errorf("Third entry timing mismatch: got %+v", result[2])
}
// Check that target content is preserved
if result[0].Content != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result[0].Content)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "More target entries than source",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Content: "Target line three.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result))
return
}
// Check that source timings are scaled appropriately
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source start time, got: %+v", result[0].StartTime)
}
if result[2].StartTime.Seconds != 5 {
t.Errorf("Last entry should have last source start time, got: %+v", result[2].StartTime)
}
// Check that content is preserved
if result[2].Content != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result[2].Content)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "More source entries than target",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Content: "Source line three.",
},
{
Number: 4,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line four.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result))
return
}
// Check that source timings are scaled appropriately
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result[0].StartTime)
}
if result[1].StartTime.Seconds != 10 {
t.Errorf("Last entry should have last source timing, got: %+v", result[1].StartTime)
}
// Check that content is preserved
if result[0].Content != "Target line one." || result[1].Content != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result)
}
// Check that numbering is correct
if result[0].Number != 1 || result[1].Number != 2 {
t.Errorf("Entry numbers should be sequential: %+v", result)
}
},
},
{
name: "Empty target entries",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
},
targetEntries: []model.SRTEntry{},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result))
}
},
},
{
name: "Empty source entries",
sourceEntries: []model.SRTEntry{},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Target line one.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result))
return
}
// Check that numbering is correct even with empty source
if result[0].Number != 1 {
t.Errorf("Entry number should be 1, got: %d", result[0].Number)
}
// Content should be preserved
if result[0].Content != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result[0].Content)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncSRTTimeline(tc.sourceEntries, tc.targetEntries)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

View file

@ -1,14 +1,9 @@
package sync package sync
import ( import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt"
"sub-cli/internal/format/vtt"
"sub-cli/internal/model"
) )
// SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file // SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file
@ -23,363 +18,9 @@ func SyncLyrics(sourceFile, targetFile string) error {
return syncSRTFiles(sourceFile, targetFile) return syncSRTFiles(sourceFile, targetFile)
} else if sourceFmt == "vtt" && targetFmt == "vtt" { } else if sourceFmt == "vtt" && targetFmt == "vtt" {
return syncVTTFiles(sourceFile, targetFile) return syncVTTFiles(sourceFile, targetFile)
} else if sourceFmt == "ass" && targetFmt == "ass" {
return syncASSFiles(sourceFile, targetFile)
} else { } else {
return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, or vtt-to-vtt)") return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, vtt-to-vtt, or ass-to-ass)")
}
}
// syncLRCFiles synchronizes two LRC files
func syncLRCFiles(sourceFile, targetFile string) error {
source, err := lrc.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source file: %w", err)
}
target, err := lrc.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target file: %w", err)
}
// Check if line counts match
if len(source.Timeline) != len(target.Content) {
fmt.Printf("Warning: Source timeline (%d entries) and target content (%d entries) have different counts. Timeline will be scaled.\n",
len(source.Timeline), len(target.Content))
}
// Apply timeline from source to target
syncedLyrics := syncLRCTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, targetFile)
}
// syncSRTFiles synchronizes two SRT files
func syncSRTFiles(sourceFile, targetFile string) error {
sourceEntries, err := srt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source SRT file: %w", err)
}
targetEntries, err := srt.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target SRT file: %w", err)
}
// Check if entry counts match
if len(sourceEntries) != len(targetEntries) {
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
len(sourceEntries), len(targetEntries))
}
// Sync the timelines
syncedEntries := syncSRTTimeline(sourceEntries, targetEntries)
// Write the synced entries to the target file
return srt.Generate(syncedEntries, targetFile)
}
// syncVTTFiles synchronizes two VTT files
func syncVTTFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := vtt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source VTT file: %w", err)
}
targetSubtitle, err := vtt.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target VTT file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Entries) != len(targetSubtitle.Entries) {
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Entries), len(targetSubtitle.Entries))
}
// Sync the timelines
syncedSubtitle := syncVTTTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return vtt.Generate(syncedSubtitle, targetFile)
}
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
result := model.Lyrics{
Metadata: target.Metadata,
Content: target.Content,
}
// Create timeline with same length as target content
result.Timeline = make([]model.Timestamp, len(target.Content))
// Use source timeline if available and lengths match
if len(source.Timeline) > 0 && len(source.Timeline) == len(target.Content) {
copy(result.Timeline, source.Timeline)
} else if len(source.Timeline) > 0 {
// If lengths don't match, scale timeline using our improved scaleTimeline function
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
}
return result
}
// syncSRTTimeline applies the timing from source SRT entries to target SRT entries
func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTEntry {
result := make([]model.SRTEntry, len(targetEntries))
// Copy target entries
copy(result, targetEntries)
// If source and target have the same number of entries, directly apply timings
if len(sourceEntries) == len(targetEntries) {
for i := range result {
result[i].StartTime = sourceEntries[i].StartTime
result[i].EndTime = sourceEntries[i].EndTime
}
} else {
// If entry counts differ, scale the timing
for i := range result {
// Calculate scaled index
sourceIdx := 0
if len(sourceEntries) > 1 {
sourceIdx = i * (len(sourceEntries) - 1) / (len(targetEntries) - 1)
}
// Ensure the index is within bounds
if sourceIdx >= len(sourceEntries) {
sourceIdx = len(sourceEntries) - 1
}
// Apply the scaled timing
result[i].StartTime = sourceEntries[sourceIdx].StartTime
// Calculate end time: if not the last entry, use duration from source
if i < len(result)-1 {
// If next source entry exists, calculate duration
var duration model.Timestamp
if sourceIdx+1 < len(sourceEntries) {
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx+1].StartTime)
} else {
// If no next source entry, use the source's end time (usually a few seconds after start)
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx].EndTime)
}
// Apply duration to next start time
result[i].EndTime = addDuration(result[i].StartTime, duration)
} else {
// For the last entry, add a fixed duration (e.g., 3 seconds)
result[i].EndTime = sourceEntries[sourceIdx].EndTime
}
}
}
// Ensure proper sequence numbering
for i := range result {
result[i].Number = i + 1
}
return result
}
// syncVTTTimeline applies the timing from source VTT subtitle to target VTT subtitle
func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
result := model.NewSubtitle()
result.Format = "vtt"
result.Title = target.Title
result.Metadata = target.Metadata
result.Styles = target.Styles
// Create entries array with same length as target
result.Entries = make([]model.SubtitleEntry, len(target.Entries))
// Copy target entries
copy(result.Entries, target.Entries)
// 如果源字幕为空或目标字幕为空,直接返回复制的目标内容
if len(source.Entries) == 0 || len(target.Entries) == 0 {
// 确保索引编号正确
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}
// If source and target have the same number of entries, directly apply timings
if len(source.Entries) == len(target.Entries) {
for i := range result.Entries {
result.Entries[i].StartTime = source.Entries[i].StartTime
result.Entries[i].EndTime = source.Entries[i].EndTime
}
} else {
// If entry counts differ, scale the timing similar to SRT sync
for i := range result.Entries {
// Calculate scaled index
sourceIdx := 0
if len(source.Entries) > 1 {
sourceIdx = i * (len(source.Entries) - 1) / (len(target.Entries) - 1)
}
// Ensure the index is within bounds
if sourceIdx >= len(source.Entries) {
sourceIdx = len(source.Entries) - 1
}
// Apply the scaled timing
result.Entries[i].StartTime = source.Entries[sourceIdx].StartTime
// Calculate end time: if not the last entry, use duration from source
if i < len(result.Entries)-1 {
// If next source entry exists, calculate duration
var duration model.Timestamp
if sourceIdx+1 < len(source.Entries) {
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx+1].StartTime)
} else {
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx].EndTime)
}
result.Entries[i].EndTime = addDuration(result.Entries[i].StartTime, duration)
} else {
// For the last entry, use the end time from source
result.Entries[i].EndTime = source.Entries[sourceIdx].EndTime
}
}
}
// Ensure proper index numbering
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}
// scaleTimeline scales a timeline to match a different number of entries
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
if targetCount <= 0 || len(timeline) == 0 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
// Handle simple case: same length
if targetCount == sourceLength {
copy(result, timeline)
return result
}
// Handle case where target is longer than source
// We need to interpolate timestamps between source entries
for i := 0; i < targetCount; i++ {
if sourceLength == 1 {
// If source has only one entry, use it for all target entries
result[i] = timeline[0]
continue
}
// Calculate a floating-point position in the source timeline
floatIndex := float64(i) * float64(sourceLength-1) / float64(targetCount-1)
lowerIndex := int(floatIndex)
upperIndex := lowerIndex + 1
// Handle boundary case
if upperIndex >= sourceLength {
upperIndex = sourceLength - 1
lowerIndex = upperIndex - 1
}
// If indices are the same, just use the source timestamp
if lowerIndex == upperIndex || lowerIndex < 0 {
result[i] = timeline[upperIndex]
} else {
// Calculate the fraction between the lower and upper indices
fraction := floatIndex - float64(lowerIndex)
// Convert timestamps to milliseconds for interpolation
lowerMS := timeline[lowerIndex].Hours*3600000 + timeline[lowerIndex].Minutes*60000 +
timeline[lowerIndex].Seconds*1000 + timeline[lowerIndex].Milliseconds
upperMS := timeline[upperIndex].Hours*3600000 + timeline[upperIndex].Minutes*60000 +
timeline[upperIndex].Seconds*1000 + timeline[upperIndex].Milliseconds
// Interpolate
resultMS := int(float64(lowerMS) + fraction*float64(upperMS-lowerMS))
// Convert back to timestamp
hours := resultMS / 3600000
resultMS %= 3600000
minutes := resultMS / 60000
resultMS %= 60000
seconds := resultMS / 1000
milliseconds := resultMS % 1000
result[i] = model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
}
return result
}
// calculateDuration calculates the time difference between two timestamps
func calculateDuration(start, end model.Timestamp) model.Timestamp {
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
endMillis := end.Hours*3600000 + end.Minutes*60000 + end.Seconds*1000 + end.Milliseconds
durationMillis := endMillis - startMillis
if durationMillis < 0 {
// Return zero duration if end is before start
return model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
}
}
hours := durationMillis / 3600000
durationMillis %= 3600000
minutes := durationMillis / 60000
durationMillis %= 60000
seconds := durationMillis / 1000
milliseconds := durationMillis % 1000
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// addDuration adds a duration to a timestamp
func addDuration(start, duration model.Timestamp) model.Timestamp {
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
durationMillis := duration.Hours*3600000 + duration.Minutes*60000 + duration.Seconds*1000 + duration.Milliseconds
totalMillis := startMillis + durationMillis
hours := totalMillis / 3600000
totalMillis %= 3600000
minutes := totalMillis / 60000
totalMillis %= 60000
seconds := totalMillis / 1000
milliseconds := totalMillis % 1000
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
} }
} }

File diff suppressed because it is too large Load diff

136
internal/sync/utils.go Normal file
View file

@ -0,0 +1,136 @@
package sync
import (
"sub-cli/internal/model"
)
// scaleTimeline scales a timeline to match a different number of entries
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
if targetCount <= 0 || len(timeline) == 0 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
// Handle simple case: same length
if targetCount == sourceLength {
copy(result, timeline)
return result
}
// Handle case where target is longer than source
// We need to interpolate timestamps between source entries
for i := 0; i < targetCount; i++ {
if sourceLength == 1 {
// If source has only one entry, use it for all target entries
result[i] = timeline[0]
continue
}
// Calculate a floating-point position in the source timeline
floatIndex := float64(i) * float64(sourceLength-1) / float64(targetCount-1)
lowerIndex := int(floatIndex)
upperIndex := lowerIndex + 1
// Handle boundary case
if upperIndex >= sourceLength {
upperIndex = sourceLength - 1
lowerIndex = upperIndex - 1
}
// If indices are the same, just use the source timestamp
if lowerIndex == upperIndex || lowerIndex < 0 {
result[i] = timeline[upperIndex]
} else {
// Calculate the fraction between the lower and upper indices
fraction := floatIndex - float64(lowerIndex)
// Convert timestamps to milliseconds for interpolation
lowerMS := timeline[lowerIndex].Hours*3600000 + timeline[lowerIndex].Minutes*60000 +
timeline[lowerIndex].Seconds*1000 + timeline[lowerIndex].Milliseconds
upperMS := timeline[upperIndex].Hours*3600000 + timeline[upperIndex].Minutes*60000 +
timeline[upperIndex].Seconds*1000 + timeline[upperIndex].Milliseconds
// Interpolate
resultMS := int(float64(lowerMS) + fraction*float64(upperMS-lowerMS))
// Convert back to timestamp
hours := resultMS / 3600000
resultMS %= 3600000
minutes := resultMS / 60000
resultMS %= 60000
seconds := resultMS / 1000
milliseconds := resultMS % 1000
result[i] = model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
}
return result
}
// calculateDuration calculates the time difference between two timestamps
func calculateDuration(start, end model.Timestamp) model.Timestamp {
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
endMillis := end.Hours*3600000 + end.Minutes*60000 + end.Seconds*1000 + end.Milliseconds
durationMillis := endMillis - startMillis
if durationMillis < 0 {
// Return zero duration if end is before start
return model.Timestamp{
Hours: 0,
Minutes: 0,
Seconds: 0,
Milliseconds: 0,
}
}
hours := durationMillis / 3600000
durationMillis %= 3600000
minutes := durationMillis / 60000
durationMillis %= 60000
seconds := durationMillis / 1000
milliseconds := durationMillis % 1000
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// addDuration adds a duration to a timestamp
func addDuration(start, duration model.Timestamp) model.Timestamp {
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
durationMillis := duration.Hours*3600000 + duration.Minutes*60000 + duration.Seconds*1000 + duration.Milliseconds
totalMillis := startMillis + durationMillis
hours := totalMillis / 3600000
totalMillis %= 3600000
minutes := totalMillis / 60000
totalMillis %= 60000
seconds := totalMillis / 1000
milliseconds := totalMillis % 1000
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}

236
internal/sync/utils_test.go Normal file
View file

@ -0,0 +1,236 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestCalculateDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
end model.Timestamp
expected model.Timestamp
}{
{
name: "Simple duration",
start: model.Timestamp{Minutes: 1, Seconds: 30},
end: model.Timestamp{Minutes: 3, Seconds: 10},
expected: model.Timestamp{Minutes: 1, Seconds: 40},
},
{
name: "Duration with hours",
start: model.Timestamp{Hours: 1, Minutes: 20},
end: model.Timestamp{Hours: 2, Minutes: 10},
expected: model.Timestamp{Hours: 0, Minutes: 50},
},
{
name: "Duration with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
end: model.Timestamp{Seconds: 20, Milliseconds: 800},
expected: model.Timestamp{Seconds: 10, Milliseconds: 300},
},
{
name: "End before start (should return zero)",
start: model.Timestamp{Minutes: 5},
end: model.Timestamp{Minutes: 3},
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
},
{
name: "Complex duration with carry",
start: model.Timestamp{Hours: 1, Minutes: 45, Seconds: 30, Milliseconds: 500},
end: model.Timestamp{Hours: 3, Minutes: 20, Seconds: 15, Milliseconds: 800},
expected: model.Timestamp{Hours: 1, Minutes: 34, Seconds: 45, Milliseconds: 300},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := calculateDuration(tc.start, tc.end)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestAddDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
duration model.Timestamp
expected model.Timestamp
}{
{
name: "Simple addition",
start: model.Timestamp{Minutes: 1, Seconds: 30},
duration: model.Timestamp{Minutes: 2, Seconds: 15},
expected: model.Timestamp{Minutes: 3, Seconds: 45},
},
{
name: "Addition with carry",
start: model.Timestamp{Minutes: 58, Seconds: 45},
duration: model.Timestamp{Minutes: 4, Seconds: 30},
expected: model.Timestamp{Hours: 1, Minutes: 3, Seconds: 15},
},
{
name: "Addition with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
duration: model.Timestamp{Seconds: 5, Milliseconds: 800},
expected: model.Timestamp{Seconds: 16, Milliseconds: 300},
},
{
name: "Zero duration",
start: model.Timestamp{Minutes: 5, Seconds: 30},
duration: model.Timestamp{},
expected: model.Timestamp{Minutes: 5, Seconds: 30},
},
{
name: "Complex addition with multiple carries",
start: model.Timestamp{Hours: 1, Minutes: 59, Seconds: 59, Milliseconds: 900},
duration: model.Timestamp{Hours: 2, Minutes: 10, Seconds: 15, Milliseconds: 200},
expected: model.Timestamp{Hours: 4, Minutes: 10, Seconds: 15, Milliseconds: 100},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := addDuration(tc.start, tc.duration)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestScaleTimeline(t *testing.T) {
testCases := []struct {
name string
timeline []model.Timestamp
targetCount int
expected []model.Timestamp
}{
{
name: "Same length timeline",
timeline: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
{Seconds: 3},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
{Seconds: 3},
},
},
{
name: "Empty timeline",
timeline: []model.Timestamp{},
targetCount: 3,
expected: []model.Timestamp{},
},
{
name: "Zero target count",
timeline: []model.Timestamp{
{Seconds: 1},
{Seconds: 2},
},
targetCount: 0,
expected: []model.Timestamp{},
},
{
name: "Single item timeline",
timeline: []model.Timestamp{
{Seconds: 5},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 5},
{Seconds: 5},
{Seconds: 5},
},
},
{
name: "Scale up timeline",
timeline: []model.Timestamp{
{Seconds: 0},
{Seconds: 10},
},
targetCount: 5,
expected: []model.Timestamp{
{Seconds: 0},
{Seconds: 2, Milliseconds: 500},
{Seconds: 5},
{Seconds: 7, Milliseconds: 500},
{Seconds: 10},
},
},
{
name: "Scale down timeline",
timeline: []model.Timestamp{
{Seconds: 0},
{Seconds: 5},
{Seconds: 10},
{Seconds: 15},
{Seconds: 20},
},
targetCount: 3,
expected: []model.Timestamp{
{Seconds: 0},
{Seconds: 10},
{Seconds: 20},
},
},
{
name: "Target count 1",
timeline: []model.Timestamp{
{Seconds: 5},
{Seconds: 10},
{Seconds: 15},
},
targetCount: 1,
expected: []model.Timestamp{
{Seconds: 5},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := scaleTimeline(tc.timeline, tc.targetCount)
if len(result) != len(tc.expected) {
t.Errorf("Expected result length %d, got %d", len(tc.expected), len(result))
return
}
for i := range result {
// Allow 1ms difference due to floating point calculations
if abs(result[i].Hours - tc.expected[i].Hours) > 0 ||
abs(result[i].Minutes - tc.expected[i].Minutes) > 0 ||
abs(result[i].Seconds - tc.expected[i].Seconds) > 0 ||
abs(result[i].Milliseconds - tc.expected[i].Milliseconds) > 1 {
t.Errorf("At index %d: expected %+v, got %+v", i, tc.expected[i], result[i])
}
}
})
}
}
// Helper function for timestamp comparison
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

104
internal/sync/vtt.go Normal file
View file

@ -0,0 +1,104 @@
package sync
import (
"fmt"
"sub-cli/internal/format/vtt"
"sub-cli/internal/model"
)
// syncVTTFiles synchronizes two VTT files
func syncVTTFiles(sourceFile, targetFile string) error {
sourceSubtitle, err := vtt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source VTT file: %w", err)
}
targetSubtitle, err := vtt.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target VTT file: %w", err)
}
// Check if entry counts match
if len(sourceSubtitle.Entries) != len(targetSubtitle.Entries) {
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
len(sourceSubtitle.Entries), len(targetSubtitle.Entries))
}
// Sync the timelines
syncedSubtitle := syncVTTTimeline(sourceSubtitle, targetSubtitle)
// Write the synced subtitle to the target file
return vtt.Generate(syncedSubtitle, targetFile)
}
// syncVTTTimeline applies the timing from source VTT subtitle to target VTT subtitle
func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
result := model.NewSubtitle()
result.Format = "vtt"
result.Title = target.Title
result.Metadata = target.Metadata
result.Styles = target.Styles
// Create entries array with same length as target
result.Entries = make([]model.SubtitleEntry, len(target.Entries))
// Copy target entries
copy(result.Entries, target.Entries)
// If source subtitle is empty or target subtitle is empty, return copied target
if len(source.Entries) == 0 || len(target.Entries) == 0 {
// Ensure proper index numbering
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}
// If source and target have the same number of entries, directly apply timings
if len(source.Entries) == len(target.Entries) {
for i := range result.Entries {
result.Entries[i].StartTime = source.Entries[i].StartTime
result.Entries[i].EndTime = source.Entries[i].EndTime
}
} else {
// If entry counts differ, scale the timing similar to SRT sync
for i := range result.Entries {
// Calculate scaled index
sourceIdx := 0
if len(source.Entries) > 1 {
sourceIdx = i * (len(source.Entries) - 1) / (len(target.Entries) - 1)
}
// Ensure the index is within bounds
if sourceIdx >= len(source.Entries) {
sourceIdx = len(source.Entries) - 1
}
// Apply the scaled timing
result.Entries[i].StartTime = source.Entries[sourceIdx].StartTime
// Calculate end time: if not the last entry, use duration from source
if i < len(result.Entries)-1 {
// If next source entry exists, calculate duration
var duration model.Timestamp
if sourceIdx+1 < len(source.Entries) {
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx+1].StartTime)
} else {
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx].EndTime)
}
result.Entries[i].EndTime = addDuration(result.Entries[i].StartTime, duration)
} else {
// For the last entry, use the end time from source
result.Entries[i].EndTime = source.Entries[sourceIdx].EndTime
}
}
}
// Ensure proper index numbering
for i := range result.Entries {
result.Entries[i].Index = i + 1
}
return result
}

342
internal/sync/vtt_test.go Normal file
View file

@ -0,0 +1,342 @@
package sync
import (
"testing"
"sub-cli/internal/model"
)
func TestSyncVTTTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Subtitle
target model.Subtitle
verify func(t *testing.T, result model.Subtitle)
}{
{
name: "Equal entry counts",
source: model.Subtitle{
Format: "vtt",
Title: "Source VTT",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line three.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Target VTT",
Styles: map[string]string{
"style1": ".style1 { color: red; }",
},
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
Styles: map[string]string{
"align": "start",
"position": "10%",
},
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
Styles: map[string]string{
"align": "middle",
},
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// Check that source timings are applied to target entries
if result.Entries[0].StartTime.Seconds != 1 || result.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v", result.Entries[0])
}
if result.Entries[1].StartTime.Seconds != 5 || result.Entries[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing mismatch: got %+v", result.Entries[1])
}
if result.Entries[2].StartTime.Seconds != 9 || result.Entries[2].EndTime.Seconds != 12 {
t.Errorf("Third entry timing mismatch: got %+v", result.Entries[2])
}
// Check that target content is preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
}
// Check that styles are preserved
if result.Entries[0].Styles["align"] != "start" || result.Entries[0].Styles["position"] != "10%" {
t.Errorf("Styles should be preserved, got: %+v", result.Entries[0].Styles)
}
// Check that global styles are preserved
if result.Styles["style1"] != ".style1 { color: red; }" {
t.Errorf("Global styles should be preserved, got: %+v", result.Styles)
}
// Check that numbering is correct
if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 || result.Entries[2].Index != 3 {
t.Errorf("Entry indices should be sequential: %+v", result.Entries)
}
},
},
{
name: "More target entries than source",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Target VTT",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// First entry should use first source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
}
// Last entry should use last source timing
if result.Entries[2].StartTime.Seconds != 5 {
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[2].StartTime)
}
// Check that target content is preserved
if result.Entries[2].Text != "Target line three." {
t.Errorf("Content should be preserved, got: %s", result.Entries[2].Text)
}
// Check that title is preserved
if result.Title != "Target VTT" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
{
name: "More source entries than target",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 3},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 4},
EndTime: model.Timestamp{Seconds: 6},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 7},
EndTime: model.Timestamp{Seconds: 9},
Text: "Source line three.",
},
{
Index: 4,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line four.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Metadata: map[string]string{
"Region": "metadata region",
},
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result.Entries))
return
}
// First entry should have first source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
}
// Last entry should have last source timing
if result.Entries[1].StartTime.Seconds != 10 {
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[1].StartTime)
}
// Check that metadata is preserved
if result.Metadata["Region"] != "metadata region" {
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
}
// Check that target content is preserved
if result.Entries[0].Text != "Target line one." || result.Entries[1].Text != "Target line two." {
t.Errorf("Content should be preserved, got: %+v", result.Entries)
}
},
},
{
name: "Empty target entries",
source: model.Subtitle{
Format: "vtt",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
},
},
target: model.Subtitle{
Format: "vtt",
Title: "Empty Target",
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result.Entries))
}
// Title should be preserved
if result.Title != "Empty Target" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
{
name: "Empty source entries",
source: model.Subtitle{
Format: "vtt",
},
target: model.Subtitle{
Format: "vtt",
Title: "Target with content",
Entries: []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 10},
EndTime: model.Timestamp{Seconds: 15},
Text: "Target line one.",
},
},
},
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result.Entries))
return
}
// Timing should be preserved since source is empty
if result.Entries[0].StartTime.Seconds != 10 || result.Entries[0].EndTime.Seconds != 15 {
t.Errorf("Timing should match target when source is empty, got: %+v", result.Entries[0])
}
// Content should be preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
}
// Title should be preserved
if result.Title != "Target with content" {
t.Errorf("Title should be preserved, got: %s", result.Title)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncVTTTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}

15
internal/testdata/test.ass vendored Normal file
View file

@ -0,0 +1,15 @@
[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Title: ASS Test File
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,First line
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Second line
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Third line