How I Automated Subtitle Timestamp Adjustments

Share

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:

  1. 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 the ReadFile("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")

}
  1. 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.