Files
silo-server/internal/subtitles/subsource/subsource.go

345 lines
9.5 KiB
Go

// internal/subtitles/subsource/subsource.go
package subsource
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
const defaultBaseURL = "https://api.subsource.net/api/v1"
// languageMap maps ISO 639-1 codes to full English names used by SubSource.
var languageMap = map[string]string{
"en": "english", "es": "spanish", "fr": "french", "de": "german",
"it": "italian", "pt": "portuguese", "nl": "dutch", "pl": "polish",
"sv": "swedish", "no": "norwegian", "da": "danish", "fi": "finnish",
"ru": "russian", "uk": "ukrainian", "cs": "czech", "sk": "slovak",
"hu": "hungarian", "ro": "romanian", "bg": "bulgarian", "hr": "croatian",
"sl": "slovenian", "sr": "serbian", "tr": "turkish", "el": "greek",
"he": "hebrew", "ar": "arabic", "zh": "chinese", "ja": "japanese",
"ko": "korean", "vi": "vietnamese", "th": "thai", "id": "indonesian",
"ms": "malay",
}
// Config holds the configuration for the SubSource provider.
type Config struct {
APIKey string
BaseURL string // override for testing
}
// Provider implements subtitles.Provider for SubSource.net.
type Provider struct {
client *http.Client
baseURL string
apiKey string
limiter *subtitles.RateLimiter
}
// New creates a new SubSource provider.
func New(cfg Config) *Provider {
base := cfg.BaseURL
if base == "" {
base = defaultBaseURL
}
return &Provider{
client: &http.Client{Timeout: 15 * time.Second},
baseURL: base,
apiKey: cfg.APIKey,
limiter: subtitles.NewRateLimiter(subtitles.RateLimiterConfig{
MaxRequests: 40,
Window: 10 * time.Second,
}),
}
}
func (p *Provider) Name() string { return "subsource" }
func (p *Provider) Search(ctx context.Context, req subtitles.SearchRequest) ([]subtitles.SubtitleResult, error) {
if err := p.limiter.Wait(ctx); err != nil {
return nil, err
}
// Step 1: Search for the movie/show
params := url.Values{}
if req.IMDbID != "" {
params.Set("searchType", "imdb")
params.Set("imdb", req.IMDbID)
} else {
params.Set("searchType", "text")
params.Set("q", req.Title)
}
if req.Season > 0 {
params.Set("type", "series")
params.Set("season", strconv.Itoa(req.Season))
} else {
params.Set("type", "movie")
}
if req.Year > 0 {
params.Set("year", strconv.Itoa(req.Year))
}
httpReq, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/movies/search?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("subsource: build search request: %w", err)
}
httpReq.Header.Set("X-API-Key", p.apiKey)
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("subsource: search request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("subsource: search returned %d: %s", resp.StatusCode, string(body))
}
var movieResp movieSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&movieResp); err != nil {
return nil, fmt.Errorf("subsource: decode search response: %w", err)
}
if !movieResp.Success || len(movieResp.Data) == 0 {
return nil, nil
}
// Pick the first matching result
movieID := movieResp.Data[0].MovieID
// Step 2: Get subtitles for this movie
if err := p.limiter.Wait(ctx); err != nil {
return nil, err
}
subParams := url.Values{}
subParams.Set("movieId", strconv.Itoa(movieID))
subParams.Set("limit", "100")
subParams.Set("sort", "popular")
// Map ISO languages to full names
if len(req.Languages) > 0 {
lang := req.Languages[0]
if fullName, ok := languageMap[lang]; ok {
subParams.Set("language", fullName)
} else {
subParams.Set("language", lang)
}
}
subReq, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/subtitles?"+subParams.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("subsource: build subtitle request: %w", err)
}
subReq.Header.Set("X-API-Key", p.apiKey)
subResp, err := p.client.Do(subReq)
if err != nil {
return nil, fmt.Errorf("subsource: subtitle request: %w", err)
}
defer subResp.Body.Close()
if subResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(subResp.Body)
return nil, fmt.Errorf("subsource: subtitle list returned %d: %s", subResp.StatusCode, string(body))
}
var subListResp subtitleListResponse
if err := json.NewDecoder(subResp.Body).Decode(&subListResp); err != nil {
return nil, fmt.Errorf("subsource: decode subtitle list: %w", err)
}
// If multiple languages requested, fetch remaining languages
var extraSubs []subtitleEntry
if len(req.Languages) > 1 {
for _, lang := range req.Languages[1:] {
extra, err := p.fetchSubtitles(ctx, movieID, lang)
if err != nil {
continue // best-effort for additional languages
}
extraSubs = append(extraSubs, extra...)
}
}
allSubs := append(subListResp.Data, extraSubs...)
results := make([]subtitles.SubtitleResult, 0, len(allSubs))
for _, s := range allSubs {
lang := reverseLanguageMap(s.Language)
releaseName := strings.Join(s.ReleaseInfo, " ")
format := detectFormat(releaseName)
results = append(results, subtitles.SubtitleResult{
ID: strconv.Itoa(s.SubtitleID),
Provider: "subsource",
Language: lang,
ReleaseName: releaseName,
Format: format,
Downloads: s.Downloads,
HearingImpaired: s.HearingImpaired,
})
}
return results, nil
}
// fetchSubtitles fetches subtitles for a single language.
func (p *Provider) fetchSubtitles(ctx context.Context, movieID int, lang string) ([]subtitleEntry, error) {
if err := p.limiter.Wait(ctx); err != nil {
return nil, err
}
subParams := url.Values{}
subParams.Set("movieId", strconv.Itoa(movieID))
subParams.Set("limit", "100")
subParams.Set("sort", "popular")
if fullName, ok := languageMap[lang]; ok {
subParams.Set("language", fullName)
} else {
subParams.Set("language", lang)
}
subReq, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/subtitles?"+subParams.Encode(), nil)
if err != nil {
return nil, err
}
subReq.Header.Set("X-API-Key", p.apiKey)
subResp, err := p.client.Do(subReq)
if err != nil {
return nil, err
}
defer subResp.Body.Close()
if subResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subsource: subtitle list returned %d", subResp.StatusCode)
}
var resp subtitleListResponse
if err := json.NewDecoder(subResp.Body).Decode(&resp); err != nil {
return nil, err
}
return resp.Data, nil
}
func (p *Provider) Download(ctx context.Context, id string) ([]byte, subtitles.SubtitleFormat, error) {
if err := p.limiter.Wait(ctx); err != nil {
return nil, "", err
}
httpReq, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/subtitles/"+id+"/download", nil)
if err != nil {
return nil, "", fmt.Errorf("subsource: build download request: %w", err)
}
httpReq.Header.Set("X-API-Key", p.apiKey)
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, "", fmt.Errorf("subsource: download request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("subsource: download returned %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("subsource: read download: %w", err)
}
// API returns a ZIP; extract the first subtitle file
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "zip") {
return extractSubtitleFromZip(data)
}
// Detect format from content-disposition or default to srt
contentDisp := resp.Header.Get("Content-Disposition")
format := detectFormatFromHeader(contentDisp)
return data, format, nil
}
// extractSubtitleFromZip finds the first subtitle file in a ZIP archive.
func extractSubtitleFromZip(data []byte) ([]byte, subtitles.SubtitleFormat, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, "", fmt.Errorf("subsource: open zip: %w", err)
}
subtitleExts := map[string]subtitles.SubtitleFormat{
".srt": subtitles.FormatSRT,
".ass": subtitles.FormatASS,
".ssa": subtitles.FormatSSA,
".vtt": subtitles.FormatVTT,
".sub": subtitles.FormatSUB,
}
for _, f := range reader.File {
ext := strings.ToLower(filepath.Ext(f.Name))
if format, ok := subtitleExts[ext]; ok {
rc, err := f.Open()
if err != nil {
return nil, "", fmt.Errorf("subsource: open zip entry: %w", err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, "", fmt.Errorf("subsource: read zip entry: %w", err)
}
return content, format, nil
}
}
return nil, "", fmt.Errorf("subsource: no subtitle file found in zip archive")
}
func reverseLanguageMap(name string) string {
for code, fullName := range languageMap {
if strings.EqualFold(fullName, name) {
return code
}
}
return strings.ToLower(name)
}
func detectFormat(filename string) subtitles.SubtitleFormat {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".srt":
return subtitles.FormatSRT
case ".ass":
return subtitles.FormatASS
case ".ssa":
return subtitles.FormatSSA
case ".vtt":
return subtitles.FormatVTT
case ".sub":
return subtitles.FormatSUB
default:
return subtitles.FormatSRT
}
}
func detectFormatFromHeader(contentDisposition string) subtitles.SubtitleFormat {
if contentDisposition == "" {
return subtitles.FormatSRT
}
parts := strings.Split(contentDisposition, "filename=")
if len(parts) > 1 {
filename := strings.Trim(parts[1], "\"' ")
return detectFormat(filename)
}
return subtitles.FormatSRT
}