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, ".") }