Files
unified-media-manager/internal/service/subtitle.go
2026-04-24 10:45:19 -07:00

379 lines
9.9 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type SubtitleSearchResult struct {
ID string `json:"id"`
FileName string `json:"file_name"`
Language string `json:"language"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
DownloadCount int `json:"download_count"`
ReleaseName string `json:"release_name"`
Provider string `json:"provider"`
}
type SubtitleFile struct {
Path string `json:"path"`
Language string `json:"language"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
Source string `json:"source"`
}
type SubtitleSearchOptions struct {
LanguageCodes []string
HI bool
Forced bool
}
type SubtitleService struct {
db *db.DB
apiKey string
baseURL string
httpClient *http.Client
ffmpegPath string
ffprobePath string
}
func NewSubtitleService(database *db.DB, apiKey string) *SubtitleService {
return &SubtitleService{
db: database,
apiKey: apiKey,
baseURL: "https://api.opensubtitles.com/api/v1",
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
ffmpegPath: "ffmpeg",
ffprobePath: "ffprobe",
}
}
type osSearchResponse struct {
TotalPages int `json:"total_pages"`
Data []osSubtitle `json:"data"`
}
type osSubtitle struct {
ID string `json:"id"`
FileName string `json:"file_name"`
Language string `json:"language"`
MovieFileNameMatch string `json:"movie_file_name_match"`
DownloadCount int `json:"download_count"`
HearingImpaired bool `json:"hearing_impaired"`
ForeignPartsOnly bool `json:"foreign_parts_only"`
ReleaseName string `json:"release_name"`
MovieHash string `json:"movie_hash"`
}
type osDownloadResponse struct {
Link string `json:"link"`
FileName string `json:"file_name"`
}
type osLoginResponse struct {
Token string `json:"token"`
}
type ffprobeStream struct {
Streams []ffprobeStreamInfo `json:"streams"`
}
type ffprobeStreamInfo struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Disposition ffprobeDisposition `json:"disposition"`
Tags ffprobeTags `json:"tags"`
}
type ffprobeDisposition struct {
Forced int `json:"forced"`
}
type ffprobeTags struct {
Language string `json:"language"`
Title string `json:"title"`
}
func (s *SubtitleService) osLogin(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/login", nil)
if err != nil {
return "", fmt.Errorf("create login request: %w", err)
}
req.Header.Set("Api-Key", s.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("opensubtitles login: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("opensubtitles login failed: status %d", resp.StatusCode)
}
var loginResp osLoginResponse
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
return "", fmt.Errorf("decode login response: %w", err)
}
return loginResp.Token, nil
}
func (s *SubtitleService) Search(ctx context.Context, query string, opts SubtitleSearchOptions) ([]SubtitleSearchResult, error) {
token, err := s.osLogin(ctx)
if err != nil {
return nil, fmt.Errorf("opensubtitles login: %w", err)
}
langs := strings.Join(opts.LanguageCodes, ",")
url := fmt.Sprintf("%s/subtitles?query=%s&languages=%s", s.baseURL, query, langs)
if opts.HI {
url += "&hearing_impaired=true"
}
if opts.Forced {
url += "&foreign_parts_only=true"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create search request: %w", err)
}
req.Header.Set("Api-Key", s.apiKey)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("search opensubtitles: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("opensubtitles search failed: status %d", resp.StatusCode)
}
var searchResp osSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("decode search response: %w", err)
}
var results []SubtitleSearchResult
for _, sub := range searchResp.Data {
langCode := ""
if parts := strings.Split(sub.Language, "-"); len(parts) > 0 {
langCode = strings.ToLower(parts[0])
}
results = append(results, SubtitleSearchResult{
ID: sub.ID,
FileName: sub.FileName,
Language: sub.Language,
LanguageCode: langCode,
HI: sub.HearingImpaired,
Forced: sub.ForeignPartsOnly,
DownloadCount: sub.DownloadCount,
ReleaseName: sub.ReleaseName,
Provider: "opensubtitles",
})
}
return results, nil
}
func (s *SubtitleService) Download(ctx context.Context, subtitleID string, targetDir string, baseName string, langCode string, hi bool, forced bool) (*SubtitleFile, error) {
token, err := s.osLogin(ctx)
if err != nil {
return nil, fmt.Errorf("opensubtitles login: %w", err)
}
body, _ := json.Marshal(map[string]string{"file_id": subtitleID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/download", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
req.Header.Set("Api-Key", s.apiKey)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request subtitle download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subtitle download request failed: status %d", resp.StatusCode)
}
var downloadResp osDownloadResponse
if err := json.NewDecoder(resp.Body).Decode(&downloadResp); err != nil {
return nil, fmt.Errorf("decode download response: %w", err)
}
fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadResp.Link, nil)
if err != nil {
return nil, fmt.Errorf("create file download request: %w", err)
}
fileResp, err := s.httpClient.Do(fileReq)
if err != nil {
return nil, fmt.Errorf("download subtitle file: %w", err)
}
defer fileResp.Body.Close()
if fileResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subtitle file download failed: status %d", fileResp.StatusCode)
}
filename := baseName + "." + langCode + ".srt"
if hi {
filename = baseName + "." + langCode + ".sdh.srt"
}
if forced {
filename = baseName + "." + langCode + ".forced.srt"
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return nil, fmt.Errorf("create target directory: %w", err)
}
destPath := filepath.Join(targetDir, filename)
f, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("create subtitle file: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, fileResp.Body); err != nil {
return nil, fmt.Errorf("write subtitle file: %w", err)
}
return &SubtitleFile{
Path: destPath,
Language: langCode,
LanguageCode: langCode,
HI: hi,
Forced: forced,
Source: "downloaded",
}, nil
}
func (s *SubtitleService) ExtractSubtitles(ctx context.Context, mediaFilePath string, targetDir string, baseName string) ([]SubtitleFile, error) {
probeCtx, probeCancel := context.WithTimeout(ctx, 30*time.Second)
defer probeCancel()
cmd := exec.CommandContext(probeCtx, s.ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-select_streams", "s",
mediaFilePath,
)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffprobe subtitle streams: %w", err)
}
var probeResult ffprobeStream
if err := json.Unmarshal(stdout.Bytes(), &probeResult); err != nil {
return nil, fmt.Errorf("parse ffprobe output: %w", err)
}
if len(probeResult.Streams) == 0 {
return nil, nil
}
var results []SubtitleFile
for i, stream := range probeResult.Streams {
langCode := strings.ToLower(stream.Tags.Language)
if langCode == "" {
langCode = "und"
}
hi := false
if strings.Contains(strings.ToLower(stream.Tags.Title), "sdh") {
hi = true
}
forced := stream.Disposition.Forced == 1
filename := baseName + "." + langCode + ".srt"
if hi {
filename = baseName + "." + langCode + ".sdh.srt"
}
if forced {
filename = baseName + "." + langCode + ".forced.srt"
}
outputPath := filepath.Join(targetDir, filename)
extractCtx, extractCancel := context.WithTimeout(ctx, 30*time.Second)
extractCmd := exec.CommandContext(extractCtx, s.ffmpegPath,
"-i", mediaFilePath,
"-map", fmt.Sprintf("0:s:%d", i),
"-f", "srt",
outputPath,
)
if err := extractCmd.Run(); err != nil {
slog.Error("failed to extract subtitle stream", "error", err, "stream_index", i)
extractCancel()
continue
}
extractCancel()
results = append(results, SubtitleFile{
Path: outputPath,
Language: langCode,
LanguageCode: langCode,
HI: hi,
Forced: forced,
Source: "extracted",
})
}
return results, nil
}
func BuildSubtitleBaseName(title string, year *int, season, episode int) string {
parts := []string{sanitizeSubtitleName(title)}
if year != nil {
parts = append(parts, fmt.Sprintf("%d", *year))
}
if season > 0 && episode > 0 {
parts = append(parts, fmt.Sprintf("S%02dE%02d", season, episode))
}
return strings.Join(parts, ".")
}
var nonAlphaNumRe = regexp.MustCompile(`[^a-zA-Z0-9]+`)
func sanitizeSubtitleName(s string) string {
s = nonAlphaNumRe.ReplaceAllString(s, ".")
for strings.Contains(s, "..") {
s = strings.ReplaceAll(s, "..", ".")
}
return strings.Trim(s, ".")
}