A deployment with no external subtitle providers configured answers
POST /api/v1/subtitles/search with 200 {"results": null} — byte-identical
to a search that ran and matched nothing. Clients had no way to tell "this
server cannot do that" from "nothing found for this file", so they offered
an in-player search entry point that could never succeed. That reached us
as a bug report against the clients for a feature that was simply not
enabled here.
Add GET /api/v1/subtitles/providers/status, following the per-subsystem
capability convention already used by /subtitles/ai/status and
/items/trailers/capability:
{"schema_version": 1, "enabled": true, "providers": ["opensubtitles"]}
Provider names are safe for any authenticated viewer — they already travel
in every SubtitleResult.provider and DownloadedSubtitle.provider. The
credentials behind them stay in the admin-only provider config.
Two details worth noting for review:
The whole /subtitles group is conditional on DB + S3 + repo, so on a
storage-less deployment the probe would 404 — leaving clients to interpret
exactly the ambiguous signal the probe exists to replace. An else branch
mounts the probe alone, answering enabled:false. Only one of the two
groups registers per boot.
The path is two segments on purpose: a bare /providers would shadow the
one-segment /{media_file_id} route, while /providers/status never competes
with it in chi.
Search and download behavior are unchanged.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
257 lines
6.4 KiB
Go
257 lines
6.4 KiB
Go
package subtitles
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type mockSubtitleRepo struct {
|
|
mu sync.Mutex
|
|
byKey map[string]*DownloadedSubtitle
|
|
nextID int
|
|
inserts int
|
|
}
|
|
|
|
func newMockSubtitleRepo() *mockSubtitleRepo {
|
|
return &mockSubtitleRepo{byKey: make(map[string]*DownloadedSubtitle)}
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, sub *DownloadedSubtitle) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.inserts++
|
|
m.nextID++
|
|
sub.ID = m.nextID
|
|
sub.CreatedAt = time.Now()
|
|
m.byKey[sub.S3Key] = sub
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) GetDownloadedSubtitle(context.Context, int) (*DownloadedSubtitle, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]DownloadedSubtitle, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) UpdateDownloadedSubtitle(_ context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, sub := range m.byKey {
|
|
if sub.ID == id {
|
|
sub.Language = update.Language
|
|
sub.ReleaseName = update.ReleaseName
|
|
sub.HearingImpaired = update.HearingImpaired
|
|
if sub.S3Key != update.S3Key {
|
|
delete(m.byKey, sub.S3Key)
|
|
sub.S3Key = update.S3Key
|
|
m.byKey[sub.S3Key] = sub
|
|
}
|
|
copy := *sub
|
|
return ©, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) DeleteDownloadedSubtitle(context.Context, int) (*DownloadedSubtitle, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*DownloadedSubtitle, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if sub, ok := m.byKey[s3Key]; ok {
|
|
copy := *sub
|
|
return ©, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) ListProviderConfigs(context.Context) ([]ProviderConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) GetProviderConfig(context.Context, string) (*ProviderConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSubtitleRepo) UpsertProviderConfig(context.Context, *ProviderConfig) error {
|
|
return nil
|
|
}
|
|
|
|
type mockS3Client struct {
|
|
mu sync.Mutex
|
|
keys map[string][]byte
|
|
puts int
|
|
deletes int
|
|
}
|
|
|
|
func newMockS3Client() *mockS3Client {
|
|
return &mockS3Client{keys: make(map[string][]byte)}
|
|
}
|
|
|
|
func (m *mockS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.puts++
|
|
m.keys[key] = append([]byte(nil), data...)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return append([]byte(nil), m.keys[key]...), nil
|
|
}
|
|
|
|
func (m *mockS3Client) DeleteObject(_ context.Context, _, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.deletes++
|
|
delete(m.keys, key)
|
|
return nil
|
|
}
|
|
|
|
type stubProvider struct {
|
|
name string
|
|
}
|
|
|
|
func (s stubProvider) Name() string { return s.name }
|
|
|
|
func (s stubProvider) Search(context.Context, SearchRequest) ([]SubtitleResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s stubProvider) Download(context.Context, string) ([]byte, SubtitleFormat, error) {
|
|
return nil, FormatSRT, nil
|
|
}
|
|
|
|
func TestManagerProviderNames(t *testing.T) {
|
|
manager := NewManager(newMockSubtitleRepo(), newMockS3Client(), "test-bucket")
|
|
|
|
names := manager.ProviderNames()
|
|
if names == nil {
|
|
t.Fatal("ProviderNames() = nil, want non-nil empty slice")
|
|
}
|
|
if len(names) != 0 {
|
|
t.Fatalf("ProviderNames() = %v, want empty", names)
|
|
}
|
|
|
|
manager.RegisterProvider(stubProvider{name: "subdl"})
|
|
manager.RegisterProvider(stubProvider{name: "opensubtitles"})
|
|
manager.RegisterProvider(stubProvider{name: "subsource"})
|
|
|
|
want := []string{"opensubtitles", "subdl", "subsource"}
|
|
names = manager.ProviderNames()
|
|
if len(names) != len(want) {
|
|
t.Fatalf("ProviderNames() = %v, want %v", names, want)
|
|
}
|
|
for i, name := range want {
|
|
if names[i] != name {
|
|
t.Fatalf("ProviderNames() = %v, want %v (sorted)", names, want)
|
|
}
|
|
}
|
|
|
|
manager.RemoveProvider("subdl")
|
|
names = manager.ProviderNames()
|
|
if len(names) != 2 || names[0] != "opensubtitles" || names[1] != "subsource" {
|
|
t.Fatalf("ProviderNames() after removal = %v, want [opensubtitles subsource]", names)
|
|
}
|
|
}
|
|
|
|
func TestManagerUploadStoresSubtitle(t *testing.T) {
|
|
repo := newMockSubtitleRepo()
|
|
s3 := newMockS3Client()
|
|
manager := NewManager(repo, s3, "test-bucket")
|
|
|
|
data := []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n")
|
|
sub, err := manager.Upload(context.Background(), UploadRequest{
|
|
MediaFileID: 42,
|
|
Language: "en",
|
|
Filename: "custom.en.srt",
|
|
Data: data,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upload() error = %v", err)
|
|
}
|
|
if sub.Provider != ProviderUpload {
|
|
t.Fatalf("provider = %q, want %q", sub.Provider, ProviderUpload)
|
|
}
|
|
if sub.Format != FormatSRT {
|
|
t.Fatalf("format = %q, want srt", sub.Format)
|
|
}
|
|
if repo.inserts != 1 {
|
|
t.Fatalf("inserts = %d, want 1", repo.inserts)
|
|
}
|
|
if s3.puts != 1 {
|
|
t.Fatalf("puts = %d, want 1", s3.puts)
|
|
}
|
|
}
|
|
|
|
func TestManagerUploadDedupesIdenticalContent(t *testing.T) {
|
|
repo := newMockSubtitleRepo()
|
|
s3 := newMockS3Client()
|
|
manager := NewManager(repo, s3, "test-bucket")
|
|
|
|
data := []byte("duplicate content")
|
|
first, err := manager.Upload(context.Background(), UploadRequest{
|
|
MediaFileID: 7,
|
|
Language: "en",
|
|
Filename: "a.srt",
|
|
Data: data,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first Upload() error = %v", err)
|
|
}
|
|
|
|
second, err := manager.Upload(context.Background(), UploadRequest{
|
|
MediaFileID: 7,
|
|
Language: "en",
|
|
Filename: "b.srt",
|
|
Data: data,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second Upload() error = %v", err)
|
|
}
|
|
if first.ID != second.ID {
|
|
t.Fatalf("dedup failed: ids %d vs %d", first.ID, second.ID)
|
|
}
|
|
if repo.inserts != 1 {
|
|
t.Fatalf("inserts = %d, want 1", repo.inserts)
|
|
}
|
|
if s3.puts != 1 {
|
|
t.Fatalf("puts = %d, want 1", s3.puts)
|
|
}
|
|
}
|
|
|
|
func TestManagerUploadRejectsUnsupportedFormat(t *testing.T) {
|
|
manager := NewManager(newMockSubtitleRepo(), newMockS3Client(), "test-bucket")
|
|
_, err := manager.Upload(context.Background(), UploadRequest{
|
|
MediaFileID: 1,
|
|
Language: "en",
|
|
Filename: "notes.txt",
|
|
Data: []byte("hello"),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for unsupported format")
|
|
}
|
|
}
|
|
|
|
func TestManagerUploadRejectsOversizedFile(t *testing.T) {
|
|
manager := NewManager(newMockSubtitleRepo(), newMockS3Client(), "test-bucket")
|
|
data := make([]byte, MaxUploadSize+1)
|
|
_, err := manager.Upload(context.Background(), UploadRequest{
|
|
MediaFileID: 1,
|
|
Language: "en",
|
|
Filename: "big.srt",
|
|
Data: data,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized file")
|
|
}
|
|
}
|