How I Automated Subtitle Timestamp Adjustments
A while ago, I set up my Jellyfin server to watch my favourite show, Friends, with my parents. However, I only had English subtitles and needed them in Spanish, as well as Russian for my girlfriend. My first step was downloading subtitles from opensubtitles.com, but I quickly noticed the timing was off. Realizing that fixing this manually would be incredibly time-consuming, I decided to automate the process using Go. I also wrote a second script to handle the translations.
To start, ensure your file is in .srt format. You can use any text editor; I personally prefer VS Code. Once opened, you will see that each subtitle block consists of four parts: a sequence number, a timestamp, the subtitle text, and a blank line.
- Sequence Number: The ID representing the order (starting at 1).
- Timestamp: Formatted as hours, minutes, seconds, and milliseconds. This tells the video player exactly when to display and hide the text.
- Text: The actual dialogue shown on screen.
- Blank space: Signals the player to move to the next block.
If the subtitles appear too early or too late, you can modify the timestamps. For example, if a block appears one minute too early, changing 00:00:30,000 --> 00:01:00,000 to 00:01:00,000 --> 00:01:30,000 makes the text appear at the one-minute mark for 30 seconds.
I'm sharing the two scripts I used:
- Time Adjustment: Written in Go to shift all timestamps by 10 seconds. I used the variable
shift := 10 * time.Second. Make sure the script and the .srt file are in the same folder and update theReadFile("your_file.srt")function with your filename.
package main
import (
"fmt"
"regexp"
"time"
"os"
)
func main() {
// 1. Read the file into memory
subtitlesContent, err := os.ReadFile("My.Movie.en.srt")
if err != nil {
fmt.Printf("Error reading file: %v\n", err)
return
}
// 2. Define the regex pattern for SRT timestamps (00:00:00,000)
re := regexp.MustCompile(`\d{2}:\d{2}:\d{2},\d{3}`)
// 3. Define the shift duration
shift := 48 * time.Second
// 4. Use ReplaceAllStringFunc to process every match
fixedContent := re.ReplaceAllStringFunc(string(subtitlesContent), func(match string) string {
// Parse the timestamp using a layout that matches the SRT format
// Note: We swap the comma for a period because Go's time parser expects periods for sub-seconds
layout := "15:04:05.000"
goTimeStr := regexp.MustCompile(`,`).ReplaceAllString(match, ".")
t, err := time.Parse(layout, goTimeStr)
if err != nil {
return match // If parsing fails, return original
}
// Add the 48 seconds
newTime := t.Add(shift)
// Format back to SRT style: HH:MM:SS,mmm
res := newTime.Format("15:04:05.000")
return regexp.MustCompile(`\.`).ReplaceAllString(res, ",")
})
// 5. Write the result to a new file
err = os.WriteFile("My.Movie.FIXED.ru.srt", []byte(fixedContent), 0644)
if err != nil {
fmt.Printf("Error writing file: %v\n", err)
return
}
fmt.Println("Done! Check My.Movie.FIXED.ru.srt")
}- Translation: This script requires a Google Cloud API key. Like the first one, keep the script and the .srt file in the same directory.
package main
import (
"encoding/json"
"fmt"
"html" // Added for UnescapeString
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
type Subtitle struct {
Index string
Timestamp string
Content string
}
// Replace with your actual API Key once you get it from Google Cloud Console
const apiKey = "your_api_key_here"
func main() {
// 1. Read your "Source of Truth" file
spaData, err := os.ReadFile("My.Movie.en.srt")
if err != nil {
fmt.Printf("Error: Ensure the SRT file is in this directory: %v\n", err)
return
}
// 2. Parse the file into blocks
spaBlocks := strings.Split(string(spaData), "\n\n")
re := regexp.MustCompile(`(?s)(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3})\n(.*)`)
var output strings.Builder
fmt.Println("Starting translation service...")
for _, block := range spaBlocks {
block = strings.TrimSpace(block)
if block == "" {
continue
}
match := re.FindStringSubmatch(block)
if len(match) == 4 {
index := match[1]
timestamp := match[2]
spaText := match[3]
// 3. Automated Translation with Unescaping
ruText := translateToRussian(spaText)
output.WriteString(index + "\n")
output.WriteString(timestamp + "\n")
output.WriteString(ruText + "\n\n")
// Rate limiting to avoid 429 Too Many Requests errors
time.Sleep(100 * time.Millisecond)
fmt.Printf("[%s] Translated successfully\n", index)
}
}
// 4. Save the new Russian file
err = os.WriteFile("My.Movie.ru.srt", []byte(output.String()), 0644)
if err != nil {
fmt.Printf("Error writing file: %v\n", err)
return
}
fmt.Println("\nSuccess! Your 'Source of Truth' Russian file is ready.")
}
func translateToRussian(text string) string {
// Remove any existing HTML tags from the source to prevent API confusion
cleanSource := regexp.MustCompile("<[^>]*>").ReplaceAllString(text, "")
// Construct the Google Translate v2 API URL
endpoint := fmt.Sprintf(
"https://translation.googleapis.com/language/translate/v2?key=%s&source=es&target=ru&q=%s",
apiKey, url.QueryEscape(cleanSource),
)
resp, err := http.Get(endpoint)
if err != nil {
return "[Error: Connection Failed]"
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
Translations []struct {
TranslatedText string `json:"translatedText"`
} `json:"translations"`
} `json:"data"`
}
json.Unmarshal(body, &result)
if len(result.Data.Translations) > 0 {
// !!! CRITICAL STEP: Unescape the HTML entities returned by the API
translated := result.Data.Translations[0].TranslatedText
return html.UnescapeString(translated)
}
return text // Fallback to original text if translation fails
}And voila, our movies or shows now have subtitles in the language we want.
Note: I encourage you to expand these scripts! You could automate them to scan for files or even build a simple UI to process multiple files simultaneously.
Good luck and enjoy your shows!
Andrés Villamarín.