Files
silo-server/internal/subtitles/manager.go
T

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
}