373 lines
9.8 KiB
Go
373 lines
9.8 KiB
Go
// internal/subtitles/manager.go
|
|
package subtitles
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
// ErrSubtitleNotFound indicates the requested subtitle record does not exist.
|
|
ErrSubtitleNotFound = errors.New("subtitle not found")
|
|
// ErrSubtitleLanguageConflict indicates another subtitle already uses the target S3 key.
|
|
ErrSubtitleLanguageConflict = errors.New("subtitle with this language already exists for this file")
|
|
)
|
|
|
|
// Manager orchestrates subtitle search and download across providers.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
providers map[string]Provider
|
|
repo Repository
|
|
s3 S3Client
|
|
s3Bucket string
|
|
}
|
|
|
|
// NewManager creates a new subtitle manager.
|
|
func NewManager(repo Repository, s3 S3Client, s3Bucket string) *Manager {
|
|
return &Manager{
|
|
providers: make(map[string]Provider),
|
|
repo: repo,
|
|
s3: s3,
|
|
s3Bucket: s3Bucket,
|
|
}
|
|
}
|
|
|
|
// RegisterProvider adds or replaces a provider.
|
|
func (m *Manager) RegisterProvider(p Provider) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.providers[p.Name()] = p
|
|
}
|
|
|
|
// RemoveProvider removes a provider by name.
|
|
func (m *Manager) RemoveProvider(name string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.providers, name)
|
|
}
|
|
|
|
// Search fans out to all registered providers concurrently.
|
|
func (m *Manager) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {
|
|
m.mu.RLock()
|
|
providers := make([]Provider, 0, len(m.providers))
|
|
for _, p := range m.providers {
|
|
providers = append(providers, p)
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if len(providers) == 0 {
|
|
return &SearchResponse{}, nil
|
|
}
|
|
|
|
type providerResult struct {
|
|
results []SubtitleResult
|
|
warning string
|
|
}
|
|
|
|
ch := make(chan providerResult, len(providers))
|
|
var wg sync.WaitGroup
|
|
|
|
for _, p := range providers {
|
|
wg.Add(1)
|
|
go func(prov Provider) {
|
|
defer wg.Done()
|
|
|
|
timeout := 20 * time.Second
|
|
if prov.Name() == "subsource" {
|
|
timeout = 30 * time.Second
|
|
}
|
|
pctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
results, err := prov.Search(pctx, req)
|
|
if err != nil {
|
|
ch <- providerResult{warning: fmt.Sprintf("%s: %v", prov.Name(), err)}
|
|
return
|
|
}
|
|
ch <- providerResult{results: results}
|
|
}(p)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(ch)
|
|
}()
|
|
|
|
resp := &SearchResponse{}
|
|
for pr := range ch {
|
|
if pr.warning != "" {
|
|
resp.Warnings = append(resp.Warnings, pr.warning)
|
|
continue
|
|
}
|
|
for i := range pr.results {
|
|
pr.results[i].Score = ScoreResult(pr.results[i], req)
|
|
}
|
|
resp.Results = append(resp.Results, pr.results...)
|
|
}
|
|
|
|
sort.Slice(resp.Results, func(i, j int) bool {
|
|
return resp.Results[i].Score > resp.Results[j].Score
|
|
})
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// DownloadRequest contains metadata from the search result the user selected.
|
|
type DownloadRequest struct {
|
|
ProviderName string
|
|
SubtitleID string
|
|
MediaFileID int
|
|
UserID *int
|
|
Language string
|
|
ReleaseName string
|
|
Score float64
|
|
HearingImpaired bool
|
|
}
|
|
|
|
// StoreSubtitleRequest contains metadata and content for persisting a subtitle.
|
|
type StoreSubtitleRequest struct {
|
|
MediaFileID int
|
|
UserID *int
|
|
Provider string
|
|
Language string
|
|
Format SubtitleFormat
|
|
ReleaseName string
|
|
Score float64
|
|
HearingImpaired bool
|
|
Data []byte
|
|
}
|
|
|
|
// UploadRequest contains metadata for a user-uploaded subtitle file.
|
|
type UploadRequest struct {
|
|
MediaFileID int
|
|
UserID *int
|
|
Language string
|
|
PreferUserLanguage bool
|
|
Filename string
|
|
ReleaseName string
|
|
HearingImpaired bool
|
|
Data []byte
|
|
}
|
|
|
|
// Download fetches a subtitle from a provider and stores it in S3.
|
|
func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*DownloadedSubtitle, error) {
|
|
m.mu.RLock()
|
|
prov, ok := m.providers[req.ProviderName]
|
|
m.mu.RUnlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown provider: %s", req.ProviderName)
|
|
}
|
|
|
|
data, format, err := prov.Download(ctx, req.SubtitleID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download from %s: %w", req.ProviderName, err)
|
|
}
|
|
|
|
return m.StoreSubtitle(ctx, StoreSubtitleRequest{
|
|
MediaFileID: req.MediaFileID,
|
|
UserID: req.UserID,
|
|
Provider: req.ProviderName,
|
|
Language: req.Language,
|
|
Format: format,
|
|
ReleaseName: req.ReleaseName,
|
|
Score: req.Score,
|
|
HearingImpaired: req.HearingImpaired,
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
// Upload stores a user-provided subtitle file in S3.
|
|
func (m *Manager) Upload(ctx context.Context, req UploadRequest) (*DownloadedSubtitle, error) {
|
|
if len(req.Data) == 0 {
|
|
return nil, fmt.Errorf("empty subtitle file")
|
|
}
|
|
if len(req.Data) > MaxUploadSize {
|
|
return nil, fmt.Errorf("subtitle file exceeds maximum size of %d bytes", MaxUploadSize)
|
|
}
|
|
|
|
format, err := FormatFromFilename(req.Filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
detected, err := ResolveUploadLanguage(req.Filename, format, req.Data, req.Language, req.PreferUserLanguage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
releaseName := req.ReleaseName
|
|
if releaseName == "" {
|
|
releaseName = req.Filename
|
|
}
|
|
|
|
return m.StoreSubtitle(ctx, StoreSubtitleRequest{
|
|
MediaFileID: req.MediaFileID,
|
|
UserID: req.UserID,
|
|
Provider: ProviderUpload,
|
|
Language: detected.Language,
|
|
Format: format,
|
|
ReleaseName: releaseName,
|
|
Score: 0,
|
|
HearingImpaired: req.HearingImpaired,
|
|
Data: req.Data,
|
|
})
|
|
}
|
|
|
|
func buildSubtitleS3Key(mediaFileID int, language, provider string, format SubtitleFormat, data []byte) string {
|
|
hash := fmt.Sprintf("%x", sha256.Sum256(data))[:8]
|
|
return fmt.Sprintf("subtitles/%d/%s_%s_%s.%s", mediaFileID, language, provider, hash, format)
|
|
}
|
|
|
|
// SubtitleMetadataPatch contains optional metadata updates for a downloaded subtitle.
|
|
type SubtitleMetadataPatch struct {
|
|
Language *string
|
|
ReleaseName *string
|
|
HearingImpaired *bool
|
|
}
|
|
|
|
// StoreSubtitle uploads subtitle content to S3 and records it in the database.
|
|
func (m *Manager) StoreSubtitle(ctx context.Context, req StoreSubtitleRequest) (*DownloadedSubtitle, error) {
|
|
s3Key := buildSubtitleS3Key(req.MediaFileID, req.Language, req.Provider, req.Format, req.Data)
|
|
|
|
existing, err := m.repo.GetDownloadedSubtitleByS3Key(ctx, s3Key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check duplicate: %w", err)
|
|
}
|
|
if existing != nil {
|
|
return existing, nil
|
|
}
|
|
|
|
if err := m.s3.PutObject(ctx, m.s3Bucket, s3Key, req.Data); err != nil {
|
|
return nil, fmt.Errorf("upload to s3: %w", err)
|
|
}
|
|
|
|
sub := &DownloadedSubtitle{
|
|
MediaFileID: req.MediaFileID,
|
|
Provider: req.Provider,
|
|
Language: req.Language,
|
|
Format: req.Format,
|
|
ReleaseName: req.ReleaseName,
|
|
S3Key: s3Key,
|
|
Score: req.Score,
|
|
HearingImpaired: req.HearingImpaired,
|
|
DownloadedBy: req.UserID,
|
|
}
|
|
if err := m.repo.InsertDownloadedSubtitle(ctx, sub); err != nil {
|
|
_ = m.s3.DeleteObject(ctx, m.s3Bucket, s3Key)
|
|
return nil, fmt.Errorf("insert subtitle record: %w", err)
|
|
}
|
|
|
|
return sub, nil
|
|
}
|
|
|
|
// UpdateDownloadedSubtitle updates subtitle metadata and migrates S3 keys when language changes.
|
|
func (m *Manager) UpdateDownloadedSubtitle(ctx context.Context, id int, patch SubtitleMetadataPatch) (*DownloadedSubtitle, error) {
|
|
sub, err := m.repo.GetDownloadedSubtitle(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup subtitle: %w", err)
|
|
}
|
|
if sub == nil {
|
|
return nil, ErrSubtitleNotFound
|
|
}
|
|
|
|
language := sub.Language
|
|
if patch.Language != nil {
|
|
normalized, err := NormalizeLanguageCode(*patch.Language)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
language = normalized
|
|
}
|
|
|
|
releaseName := sub.ReleaseName
|
|
if patch.ReleaseName != nil {
|
|
releaseName = strings.TrimSpace(*patch.ReleaseName)
|
|
}
|
|
|
|
hearingImpaired := sub.HearingImpaired
|
|
if patch.HearingImpaired != nil {
|
|
hearingImpaired = *patch.HearingImpaired
|
|
}
|
|
|
|
newS3Key := sub.S3Key
|
|
if language != sub.Language {
|
|
data, err := m.s3.GetObject(ctx, m.s3Bucket, sub.S3Key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch subtitle content: %w", err)
|
|
}
|
|
newS3Key = buildSubtitleS3Key(sub.MediaFileID, language, sub.Provider, sub.Format, data)
|
|
|
|
existing, err := m.repo.GetDownloadedSubtitleByS3Key(ctx, newS3Key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check duplicate: %w", err)
|
|
}
|
|
if existing != nil && existing.ID != id {
|
|
return nil, ErrSubtitleLanguageConflict
|
|
}
|
|
|
|
if newS3Key != sub.S3Key {
|
|
if err := m.s3.PutObject(ctx, m.s3Bucket, newS3Key, data); err != nil {
|
|
return nil, fmt.Errorf("upload migrated subtitle: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
updated, err := m.repo.UpdateDownloadedSubtitle(ctx, id, SubtitleMetadataUpdate{
|
|
Language: language,
|
|
ReleaseName: releaseName,
|
|
HearingImpaired: hearingImpaired,
|
|
S3Key: newS3Key,
|
|
})
|
|
if err != nil {
|
|
if newS3Key != sub.S3Key {
|
|
_ = m.s3.DeleteObject(ctx, m.s3Bucket, newS3Key)
|
|
}
|
|
return nil, err
|
|
}
|
|
if updated == nil {
|
|
return nil, ErrSubtitleNotFound
|
|
}
|
|
|
|
if newS3Key != sub.S3Key {
|
|
_ = m.s3.DeleteObject(ctx, m.s3Bucket, sub.S3Key)
|
|
}
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
// GetSubtitleContent loads a downloaded subtitle record and its S3 bytes.
|
|
func (m *Manager) GetSubtitleContent(ctx context.Context, id int) (*DownloadedSubtitle, []byte, error) {
|
|
sub, err := m.repo.GetDownloadedSubtitle(ctx, id)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("lookup subtitle: %w", err)
|
|
}
|
|
if sub == nil {
|
|
return nil, nil, ErrSubtitleNotFound
|
|
}
|
|
|
|
data, err := m.s3.GetObject(ctx, m.s3Bucket, sub.S3Key)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("fetch subtitle content: %w", err)
|
|
}
|
|
return sub, data, nil
|
|
}
|
|
|
|
// DeleteSubtitle removes a downloaded subtitle from both DB and S3.
|
|
func (m *Manager) DeleteSubtitle(ctx context.Context, id int) error {
|
|
sub, err := m.repo.DeleteDownloadedSubtitle(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete subtitle record: %w", err)
|
|
}
|
|
if sub == nil {
|
|
return nil
|
|
}
|
|
_ = m.s3.DeleteObject(ctx, m.s3Bucket, sub.S3Key)
|
|
return nil
|
|
}
|