Merge pull request #10 from Silo-Server/codex/restore-subtitle-uploads

feat(subtitles): restore upload management
This commit is contained in:
Quick
2026-05-26 18:02:27 -04:00
committed by GitHub
40 changed files with 3928 additions and 38 deletions
+1
View File
@@ -20,6 +20,7 @@ require (
)
require (
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
github.com/h2non/bimg v1.1.9
+2
View File
@@ -2,6 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/Silo-Server/silo-plugin-sdk v0.4.0 h1:DJkRROQfr/kfwnF5dUdkhmbCso1KDLZc7uK7YfJnaO0=
github.com/Silo-Server/silo-plugin-sdk v0.4.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -0,0 +1,326 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// AdminDownloadedSubtitle is the admin-facing view of a stored subtitle record.
type AdminDownloadedSubtitle struct {
ID int `json:"id"`
MediaFileID int `json:"media_file_id"`
MediaContentID string `json:"media_content_id,omitempty"`
Provider string `json:"provider"`
Language string `json:"language"`
Format string `json:"format"`
ReleaseName string `json:"release_name"`
Score float64 `json:"score"`
HearingImpaired bool `json:"hearing_impaired"`
CreatedAt time.Time `json:"created_at"`
DownloadedBy *int `json:"downloaded_by,omitempty"`
UploaderUsername string `json:"uploader_username"`
MediaTitle string `json:"media_title"`
MediaType string `json:"media_type"`
FilePath string `json:"file_path"`
}
type adminDownloadedSubtitlesResponse struct {
Subtitles []AdminDownloadedSubtitle `json:"subtitles"`
Total int `json:"total"`
Uploads int `json:"uploads"`
Provider int `json:"provider_downloads"`
}
type patchDownloadedSubtitleRequest struct {
Language *string `json:"language"`
ReleaseName *string `json:"release_name"`
HearingImpaired *bool `json:"hearing_impaired"`
}
// SetDownloadedSubtitleDeps wires optional dependencies for downloaded subtitle admin routes.
func (h *AdminSubtitleHandler) SetDownloadedSubtitleDeps(pool *pgxpool.Pool, manager *subtitles.Manager) {
h.pool = pool
h.manager = manager
}
// HandleListDownloadedSubtitles handles GET /api/v1/admin/subtitles.
func (h *AdminSubtitleHandler) HandleListDownloadedSubtitles(w http.ResponseWriter, r *http.Request) {
if h.pool == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
return
}
limit, offset := parsePagination(r)
q := r.URL.Query()
var (
args []any
conditions []string
argIndex = 1
)
if provider := strings.TrimSpace(q.Get("provider")); provider != "" {
conditions = append(conditions, "ds.provider = $"+strconv.Itoa(argIndex))
args = append(args, provider)
argIndex++
}
if language := strings.TrimSpace(q.Get("language")); language != "" {
conditions = append(conditions, "ds.language = $"+strconv.Itoa(argIndex))
args = append(args, language)
argIndex++
}
if userIDStr := strings.TrimSpace(q.Get("user_id")); userIDStr != "" {
userID, err := strconv.Atoi(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user_id")
return
}
conditions = append(conditions, "ds.downloaded_by = $"+strconv.Itoa(argIndex))
args = append(args, userID)
argIndex++
}
if mediaFileIDStr := strings.TrimSpace(q.Get("media_file_id")); mediaFileIDStr != "" {
mediaFileID, err := strconv.Atoi(mediaFileIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
return
}
conditions = append(conditions, "ds.media_file_id = $"+strconv.Itoa(argIndex))
args = append(args, mediaFileID)
argIndex++
}
if search := strings.TrimSpace(q.Get("q")); search != "" {
conditions = append(conditions, "ds.release_name ILIKE $"+strconv.Itoa(argIndex))
args = append(args, "%"+search+"%")
argIndex++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = " WHERE " + strings.Join(conditions, " AND ")
}
countQuery := `SELECT COUNT(*) FROM downloaded_subtitles ds` + whereClause
var total int
if err := h.pool.QueryRow(r.Context(), countQuery, args...).Scan(&total); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitles")
return
}
statsQuery := `
SELECT
COUNT(*) FILTER (WHERE ds.provider = 'upload'),
COUNT(*) FILTER (WHERE ds.provider <> 'upload')
FROM downloaded_subtitles ds` + whereClause
var uploads, providerDownloads int
if err := h.pool.QueryRow(r.Context(), statsQuery, args...).Scan(&uploads, &providerDownloads); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitle stats")
return
}
listQuery := `
SELECT
ds.id,
ds.media_file_id,
COALESCE(mf.content_id, ''),
ds.provider,
ds.language,
ds.format,
ds.release_name,
ds.score,
ds.hearing_impaired,
ds.created_at,
ds.downloaded_by,
COALESCE(u.username, ''),
COALESCE(ep.title, mi.title, ''),
COALESCE(CASE WHEN ep.content_id IS NOT NULL THEN 'episode' ELSE mi.type END, ''),
COALESCE(mf.file_path, '')
FROM downloaded_subtitles ds
LEFT JOIN users u ON u.id = ds.downloaded_by
LEFT JOIN media_files mf ON mf.id = ds.media_file_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
LEFT JOIN episodes ep ON ep.content_id = mf.content_id` + whereClause + `
ORDER BY ds.created_at DESC
LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
listArgs := append(append([]any{}, args...), limit, offset)
rows, err := h.pool.Query(r.Context(), listQuery, listArgs...)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list subtitles")
return
}
defer rows.Close()
subtitlesList := make([]AdminDownloadedSubtitle, 0)
for rows.Next() {
var row AdminDownloadedSubtitle
if err := rows.Scan(
&row.ID,
&row.MediaFileID,
&row.MediaContentID,
&row.Provider,
&row.Language,
&row.Format,
&row.ReleaseName,
&row.Score,
&row.HearingImpaired,
&row.CreatedAt,
&row.DownloadedBy,
&row.UploaderUsername,
&row.MediaTitle,
&row.MediaType,
&row.FilePath,
); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan subtitle row")
return
}
subtitlesList = append(subtitlesList, row)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate subtitles")
return
}
writeJSON(w, http.StatusOK, adminDownloadedSubtitlesResponse{
Subtitles: subtitlesList,
Total: total,
Uploads: uploads,
Provider: providerDownloads,
})
}
// HandlePatchDownloadedSubtitle handles PATCH /api/v1/admin/subtitles/{id}.
func (h *AdminSubtitleHandler) HandlePatchDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
return
}
var req patchDownloadedSubtitleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
return
}
if req.Language == nil && req.ReleaseName == nil && req.HearingImpaired == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "No fields to update")
return
}
updated, err := h.manager.UpdateDownloadedSubtitle(r.Context(), id, subtitles.SubtitleMetadataPatch{
Language: req.Language,
ReleaseName: req.ReleaseName,
HearingImpaired: req.HearingImpaired,
})
if err != nil {
switch {
case errors.Is(err, subtitles.ErrSubtitleNotFound):
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
case errors.Is(err, subtitles.ErrSubtitleLanguageConflict):
writeError(w, http.StatusConflict, "conflict", "Subtitle with this language already exists for this file")
default:
if strings.Contains(err.Error(), "invalid subtitle language") {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "update_error", "Failed to update subtitle")
}
return
}
writeJSON(w, http.StatusOK, map[string]any{"subtitle": updated})
}
// HandleDownloadDownloadedSubtitle handles GET /api/v1/admin/subtitles/{id}/download.
func (h *AdminSubtitleHandler) HandleDownloadDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
return
}
sub, data, err := h.manager.GetSubtitleContent(r.Context(), id)
if err != nil {
if errors.Is(err, subtitles.ErrSubtitleNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
return
}
writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle")
return
}
filename := subtitleDownloadFilename(sub)
w.Header().Set("Content-Type", subtitles.SubtitleContentType(sub.Format))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
// HandleDeleteDownloadedSubtitle handles DELETE /api/v1/admin/subtitles/{id}.
func (h *AdminSubtitleHandler) HandleDeleteDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
return
}
sub, err := h.repo.GetDownloadedSubtitle(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "lookup_error", "Failed to look up subtitle")
return
}
if sub == nil {
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
return
}
if err := h.manager.DeleteSubtitle(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "delete_error", "Failed to delete subtitle")
return
}
w.WriteHeader(http.StatusNoContent)
}
func subtitleDownloadFilename(sub *subtitles.DownloadedSubtitle) string {
base := strings.TrimSpace(sub.ReleaseName)
if base == "" {
base = fmt.Sprintf("subtitle-%d", sub.ID)
}
base = path.Base(base)
base = strings.TrimSuffix(base, path.Ext(base))
if base == "" || base == "." {
base = fmt.Sprintf("subtitle-%d", sub.ID)
}
return fmt.Sprintf("%s.%s", base, sub.Format)
}
@@ -0,0 +1,232 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
}
func withSubtitleRouteParam(req *http.Request, key, value string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func TestHandlePatchDownloadedSubtitleUpdatesMetadata(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
repo.subtitles[7] = &subtitles.DownloadedSubtitle{
ID: 7,
MediaFileID: 42,
Provider: subtitles.ProviderUpload,
Language: "en",
Format: subtitles.FormatSRT,
ReleaseName: "movie.en.srt",
S3Key: "subtitles/42/en_upload_abcd1234.srt",
}
repo.byKey["subtitles/42/en_upload_abcd1234.srt"] = repo.subtitles[7]
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
"subtitles/42/en_upload_abcd1234.srt": []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"),
}}
manager := subtitles.NewManager(repo, s3, "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
body, err := json.Marshal(map[string]any{
"language": "es",
"release_name": "movie.es.srt",
"hearing_impaired": true,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/7", body)
req = withSubtitleRouteParam(req, "id", "7")
rr := httptest.NewRecorder()
handler.HandlePatchDownloadedSubtitle(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
updated := repo.subtitles[7]
if updated.Language != "es" {
t.Fatalf("language = %q, want es", updated.Language)
}
if updated.ReleaseName != "movie.es.srt" {
t.Fatalf("release_name = %q, want movie.es.srt", updated.ReleaseName)
}
if !updated.HearingImpaired {
t.Fatal("expected hearing_impaired=true")
}
if updated.S3Key == "subtitles/42/en_upload_abcd1234.srt" {
t.Fatalf("expected migrated s3 key, got %q", updated.S3Key)
}
if len(s3.putKeys) != 1 {
t.Fatalf("putKeys = %d, want 1", len(s3.putKeys))
}
if len(s3.deletedKeys) != 1 {
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
}
}
func TestHandlePatchDownloadedSubtitleNotFound(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
body := []byte(`{"language":"fr"}`)
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/404", body)
req = withSubtitleRouteParam(req, "id", "404")
rr := httptest.NewRecorder()
handler.HandlePatchDownloadedSubtitle(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
func TestHandleDownloadDownloadedSubtitle(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
content := []byte("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n")
repo.subtitles[3] = &subtitles.DownloadedSubtitle{
ID: 3,
MediaFileID: 10,
Provider: subtitles.ProviderUpload,
Language: "en",
Format: subtitles.FormatVTT,
ReleaseName: "sample.vtt",
S3Key: "subtitles/10/en_upload_deadbeef.vtt",
CreatedAt: time.Now(),
}
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
"subtitles/10/en_upload_deadbeef.vtt": content,
}}
manager := subtitles.NewManager(repo, s3, "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
req := newAdminSubtitleRequest(http.MethodGet, "/admin/subtitles/3/download", nil)
req = withSubtitleRouteParam(req, "id", "3")
rr := httptest.NewRecorder()
handler.HandleDownloadDownloadedSubtitle(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got != "text/vtt; charset=utf-8" {
t.Fatalf("content-type = %q", got)
}
if !bytes.Contains([]byte(rr.Header().Get("Content-Disposition")), []byte("sample.vtt")) {
t.Fatalf("content-disposition = %q", rr.Header().Get("Content-Disposition"))
}
if !bytes.Equal(rr.Body.Bytes(), content) {
t.Fatalf("body mismatch")
}
}
func TestHandleDeleteDownloadedSubtitle(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
repo.subtitles[5] = &subtitles.DownloadedSubtitle{
ID: 5,
MediaFileID: 11,
Provider: "opensubtitles",
Language: "en",
Format: subtitles.FormatSRT,
S3Key: "subtitles/11/en_opensubtitles_abcd1234.srt",
}
repo.byKey[repo.subtitles[5].S3Key] = repo.subtitles[5]
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
repo.subtitles[5].S3Key: []byte("subtitle"),
}}
manager := subtitles.NewManager(repo, s3, "test-bucket")
handler := NewAdminSubtitleHandler(repo)
handler.SetDownloadedSubtitleDeps(nil, manager)
req := newAdminSubtitleRequest(http.MethodDelete, "/admin/subtitles/5", nil)
req = withSubtitleRouteParam(req, "id", "5")
rr := httptest.NewRecorder()
handler.HandleDeleteDownloadedSubtitle(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rr.Code)
}
if _, ok := repo.subtitles[5]; ok {
t.Fatal("expected subtitle record deleted")
}
if len(s3.deletedKeys) != 1 {
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
}
}
type trackingHandlerS3Client struct {
objects map[string][]byte
putKeys []string
deletedKeys []string
}
func (c *trackingHandlerS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
if c.objects == nil {
c.objects = make(map[string][]byte)
}
c.objects[key] = append([]byte(nil), data...)
c.putKeys = append(c.putKeys, key)
return nil
}
func (c *trackingHandlerS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
if data, ok := c.objects[key]; ok {
return append([]byte(nil), data...), nil
}
return nil, context.Canceled
}
func (c *trackingHandlerS3Client) DeleteObject(_ context.Context, _, key string) error {
delete(c.objects, key)
c.deletedKeys = append(c.deletedKeys, key)
return nil
}
func TestSubtitleDownloadFilename(t *testing.T) {
sub := &subtitles.DownloadedSubtitle{
ID: 9,
ReleaseName: "../unsafe/path/movie.en.srt",
Format: subtitles.FormatSRT,
}
got := subtitleDownloadFilename(sub)
if got != "movie.en.srt" {
t.Fatalf("filename = %q, want movie.en.srt", got)
}
if strconv.Itoa(sub.ID) == "" {
t.Fatal("unexpected")
}
}
+3
View File
@@ -13,6 +13,7 @@ import (
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// SubtitleProviderFactory creates a Provider from a config. Allows testing without real providers.
@@ -21,6 +22,8 @@ type SubtitleProviderFactory func(cfg *subtitles.ProviderConfig) (subtitles.Prov
// AdminSubtitleHandler handles admin operations for subtitle provider management.
type AdminSubtitleHandler struct {
repo subtitles.Repository
manager *subtitles.Manager
pool *pgxpool.Pool
providerFactory SubtitleProviderFactory
}
+70
View File
@@ -0,0 +1,70 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// MediaFileAuthorizer validates that the authenticated user can access a media file.
type MediaFileAuthorizer struct {
FileResolver FilePathResolver
ItemAccess PlaybackItemAccessChecker
EpisodeLookup PlaybackEpisodeLookup
}
// Authorize returns the media file when the caller may access it, or catalog.ErrItemNotFound.
func (a *MediaFileAuthorizer) Authorize(r *http.Request, fileID int) (*models.MediaFile, error) {
if a == nil || a.FileResolver == nil || a.ItemAccess == nil {
return nil, fmt.Errorf("media file authorization dependencies not configured")
}
file, err := a.FileResolver.GetByID(r.Context(), fileID)
if err != nil {
return nil, mapMediaFileLookupError(err)
}
if file == nil || file.MissingSince != nil {
return nil, catalog.ErrItemNotFound
}
filter := requestAccessFilter(r)
switch {
case file.EpisodeID != "":
if a.EpisodeLookup == nil {
return nil, fmt.Errorf("episode lookup not configured")
}
episode, err := a.EpisodeLookup.GetByID(r.Context(), file.EpisodeID)
if err != nil {
return nil, err
}
if episode == nil {
return nil, catalog.ErrEpisodeNotFound
}
if err := a.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil {
return nil, err
}
case file.ContentID != "":
if err := a.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil {
return nil, err
}
default:
return nil, catalog.ErrItemNotFound
}
if !catalog.FileAllowedByAccess(file, filter) {
return nil, catalog.ErrItemNotFound
}
return file, nil
}
func mapMediaFileLookupError(err error) error {
if errors.Is(err, scanner.ErrFileNotFound) {
return catalog.ErrItemNotFound
}
return err
}
@@ -0,0 +1,102 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func TestMediaFileAuthorizerMapsMissingFileToNotFound(t *testing.T) {
authorizer := &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
}))
_, err := authorizer.Authorize(req, 99)
if !errors.Is(err, catalog.ErrItemNotFound) {
t.Fatalf("Authorize() error = %v, want ErrItemNotFound", err)
}
}
func TestHandleUploadMissingMediaFileReturns404(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
}
}
func TestHandleListMissingMediaFileReturns404(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleAuthRequest(http.MethodGet, "/subtitles/99", nil)
req = withProfileRouteParam(req, "media_file_id", "99")
rr := httptest.NewRecorder()
handler.HandleList(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
}
}
func TestMediaFileAuthorizerAllowsAccessibleFile(t *testing.T) {
authorizer := &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
}))
file, err := authorizer.Authorize(req, 42)
if err != nil {
t.Fatalf("Authorize() error = %v", err)
}
if file == nil || file.ID != 42 {
t.Fatalf("file = %#v, want id 42", file)
}
}
func TestMapMediaFileLookupError(t *testing.T) {
if !errors.Is(mapMediaFileLookupError(scanner.ErrFileNotFound), catalog.ErrItemNotFound) {
t.Fatal("expected scanner.ErrFileNotFound to map to catalog.ErrItemNotFound")
}
if mapMediaFileLookupError(errors.New("db down")) == nil {
t.Fatal("expected unrelated error to pass through")
}
}
+1 -1
View File
@@ -1780,7 +1780,7 @@ func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*mode
}
file, err := h.fileResolver.GetByID(r.Context(), fileID)
if err != nil {
return nil, err
return nil, mapMediaFileLookupError(err)
}
if file == nil || file.MissingSince != nil {
return nil, catalog.ErrItemNotFound
+179 -3
View File
@@ -3,16 +3,40 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/go-chi/chi/v5"
)
const (
subtitleUploadMaxSize = subtitles.MaxUploadSize
// Allow multipart framing and small form fields above the file size cap.
subtitleUploadMaxBodySize = subtitleUploadMaxSize + (256 << 10)
)
func parseSubtitleMultipartForm(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, subtitleUploadMaxBodySize)
if err := r.ParseMultipartForm(subtitleUploadMaxSize); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
} else {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
}
return false
}
return true
}
// SubtitleMediaResolver looks up media metadata for subtitle search.
type SubtitleMediaResolver interface {
GetMediaFileWithMetadata(ctx context.Context, fileID int) (*MediaFileMetadata, error)
@@ -36,9 +60,10 @@ type MediaFileMetadata struct {
// SubtitleSearchHandler handles user-facing subtitle search operations.
type SubtitleSearchHandler struct {
manager *subtitles.Manager
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
manager *subtitles.Manager
repo subtitles.Repository
mediaResolver SubtitleMediaResolver
FileAuthorizer *MediaFileAuthorizer
}
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
@@ -70,6 +95,23 @@ type downloadSubtitleRequest struct {
HearingImpaired bool `json:"hearing_impaired"`
}
func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *http.Request, fileID int) bool {
if h.FileAuthorizer == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Media file authorization is not configured")
return false
}
if _, err := h.FileAuthorizer.Authorize(r, fileID); err != nil {
switch {
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
}
return false
}
return true
}
// HandleSearch handles POST /api/v1/subtitles/search
func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
var req searchSubtitlesRequest
@@ -78,6 +120,10 @@ func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Requ
return
}
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
return
}
meta, err := h.mediaResolver.GetMediaFileWithMetadata(r.Context(), req.MediaFileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "metadata_error", "Failed to look up media metadata")
@@ -124,6 +170,10 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
return
}
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
return
}
userID := apimw.GetUserID(r.Context())
sub, err := h.manager.Download(r.Context(), subtitles.DownloadRequest{
@@ -145,6 +195,115 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
}
// HandleUpload handles POST /api/v1/subtitles/upload
func (h *SubtitleSearchHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
if !parseSubtitleMultipartForm(w, r) {
return
}
mediaFileID, err := strconv.Atoi(strings.TrimSpace(r.FormValue("media_file_id")))
if err != nil || mediaFileID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
return
}
if !h.authorizeMediaFile(w, r, mediaFileID) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
return
}
if len(data) > subtitleUploadMaxSize {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
return
}
releaseName := strings.TrimSpace(r.FormValue("release_name"))
hearingImpaired := parseBoolFormValue(r.FormValue("hearing_impaired"))
userID := apimw.GetUserID(r.Context())
userLanguage := strings.TrimSpace(r.FormValue("language"))
preferUserLanguage := parseBoolFormValue(r.FormValue("language_override"))
sub, err := h.manager.Upload(r.Context(), subtitles.UploadRequest{
MediaFileID: mediaFileID,
UserID: &userID,
Language: userLanguage,
PreferUserLanguage: preferUserLanguage,
Filename: header.Filename,
ReleaseName: releaseName,
HearingImpaired: hearingImpaired,
Data: data,
})
if err != nil {
switch {
case strings.Contains(err.Error(), "unsupported subtitle format"),
strings.Contains(err.Error(), "missing file extension"),
strings.Contains(err.Error(), "empty subtitle file"),
strings.Contains(err.Error(), "could not detect subtitle language"),
strings.Contains(err.Error(), "invalid subtitle language"):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case strings.Contains(err.Error(), "exceeds maximum size"):
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
default:
slog.Error("subtitle upload failed", "media_file_id", mediaFileID, "error", err)
writeError(w, http.StatusInternalServerError, "upload_error", "Failed to upload subtitle")
}
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
}
// HandleDetectLanguage handles POST /api/v1/subtitles/detect-language
func (h *SubtitleSearchHandler) HandleDetectLanguage(w http.ResponseWriter, r *http.Request) {
if !parseSubtitleMultipartForm(w, r) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
return
}
if len(data) > subtitleUploadMaxSize {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
return
}
format, err := subtitles.FormatFromFilename(header.Filename)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
userLanguage := strings.TrimSpace(r.FormValue("language"))
detected, err := subtitles.ResolveUploadLanguage(header.Filename, format, data, userLanguage, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusOK, detected)
}
// HandleList handles GET /api/v1/subtitles/{media_file_id}
func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Request) {
mediaFileID, err := strconv.Atoi(chi.URLParam(r, "media_file_id"))
@@ -153,6 +312,10 @@ func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Reques
return
}
if !h.authorizeMediaFile(w, r, mediaFileID) {
return
}
subs, err := h.repo.ListDownloadedSubtitles(r.Context(), mediaFileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list subtitles")
@@ -180,6 +343,10 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
return
}
if !h.authorizeMediaFile(w, r, sub.MediaFileID) {
return
}
claims := apimw.GetClaims(r.Context())
isAdmin := claims != nil && claims.Role == "admin"
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
@@ -204,3 +371,12 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
func parseBoolFormValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
@@ -0,0 +1,330 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
type stubSubtitleMediaResolver struct {
meta *MediaFileMetadata
}
func (s stubSubtitleMediaResolver) GetMediaFileWithMetadata(context.Context, int) (*MediaFileMetadata, error) {
return s.meta, nil
}
type stubMediaFileResolver struct {
file *models.MediaFile
err error
}
func (s stubMediaFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) {
return s.file, s.err
}
type stubItemAccessChecker struct {
err error
}
func (s stubItemAccessChecker) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
return s.err
}
type stubEpisodeLookup struct {
episode *models.Episode
}
func (s stubEpisodeLookup) GetByID(context.Context, string) (*models.Episode, error) {
return s.episode, nil
}
func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
req := httptest.NewRequest(method, path, body)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
}
func newSubtitleUploadRequest(t *testing.T, mediaFileID int, language, filename string, content []byte) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("media_file_id", strconv.Itoa(mediaFileID)); err != nil {
t.Fatalf("write media_file_id: %v", err)
}
if err := writer.WriteField("language", language); err != nil {
t.Fatalf("write language: %v", err)
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/upload", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestHandleUploadSuccess(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 42, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp struct {
Subtitle subtitles.DownloadedSubtitle `json:"subtitle"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Subtitle.Provider != subtitles.ProviderUpload {
t.Fatalf("provider = %q, want upload", resp.Subtitle.Provider)
}
}
func TestHandleUploadUnauthorizedMediaFile(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("hello"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
func TestHandleUploadRejectsBadExtension(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(t, 42, "en", "notes.txt", []byte("hello"))
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}
func TestHandleUploadRejectsOversizedBody(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleUploadRequest(
t,
42,
"en",
"huge.srt",
make([]byte, subtitleUploadMaxBodySize+1),
)
rr := httptest.NewRecorder()
handler.HandleUpload(rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rr.Code)
}
}
func TestHandleDetectLanguageRejectsOversizedBody(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "huge.srt")
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(make([]byte, subtitleUploadMaxBodySize+1)); err != nil {
t.Fatalf("write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/detect-language", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rr := httptest.NewRecorder()
handler.HandleDetectLanguage(rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rr.Code)
}
}
func TestHandleDeleteRequiresAccessToMediaFile(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
repo.subtitles[1] = &subtitles.DownloadedSubtitle{
ID: 1,
MediaFileID: 42,
Provider: subtitles.ProviderUpload,
}
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
handler.FileAuthorizer = &MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
ItemAccess: stubItemAccessChecker{},
}
req := newSubtitleAuthRequest(http.MethodDelete, "/subtitles/1", nil)
req = withProfileRouteParam(req, "id", "1")
rr := httptest.NewRecorder()
handler.HandleDelete(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
type handlerMockSubtitleRepo struct {
subtitles map[int]*subtitles.DownloadedSubtitle
nextID int
byKey map[string]*subtitles.DownloadedSubtitle
}
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
return &handlerMockSubtitleRepo{
subtitles: make(map[int]*subtitles.DownloadedSubtitle),
byKey: make(map[string]*subtitles.DownloadedSubtitle),
}
}
func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, sub *subtitles.DownloadedSubtitle) error {
m.nextID++
sub.ID = m.nextID
m.subtitles[sub.ID] = sub
m.byKey[sub.S3Key] = sub
return nil
}
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
if sub, ok := m.subtitles[id]; ok {
copy := *sub
return &copy, nil
}
return nil, nil
}
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
return nil, nil
}
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
sub := m.subtitles[id]
delete(m.subtitles, id)
return sub, nil
}
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*subtitles.DownloadedSubtitle, error) {
if sub, ok := m.byKey[s3Key]; ok {
copy := *sub
return &copy, nil
}
return nil, nil
}
func (m *handlerMockSubtitleRepo) UpdateDownloadedSubtitle(_ context.Context, id int, update subtitles.SubtitleMetadataUpdate) (*subtitles.DownloadedSubtitle, error) {
sub, ok := m.subtitles[id]
if !ok {
return nil, nil
}
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 &copy, nil
}
func (m *handlerMockSubtitleRepo) ListProviderConfigs(context.Context) ([]subtitles.ProviderConfig, error) {
return nil, nil
}
func (m *handlerMockSubtitleRepo) GetProviderConfig(context.Context, string) (*subtitles.ProviderConfig, error) {
return nil, nil
}
func (m *handlerMockSubtitleRepo) UpsertProviderConfig(context.Context, *subtitles.ProviderConfig) error {
return nil
}
type handlerMockS3Client struct{}
func newMockS3ClientForHandler() *handlerMockS3Client {
return &handlerMockS3Client{}
}
func (handlerMockS3Client) PutObject(context.Context, string, string, []byte) error {
return nil
}
func (handlerMockS3Client) GetObject(context.Context, string, string) ([]byte, error) {
return nil, nil
}
func (handlerMockS3Client) DeleteObject(context.Context, string, string) error {
return nil
}
+1
View File
@@ -39,6 +39,7 @@ var demoBlockedRoutes = []blockedRoute{
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/downloads"},
{methods: []string{"POST"}, prefix: "/api/v1/history-imports"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/download"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/upload"},
{methods: []string{"DELETE"}, prefix: "/api/v1/subtitles/"},
}
+23 -1
View File
@@ -714,6 +714,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Admin subtitle config handler only needs the DB repo — no S3 required.
var adminSubtitleHandler *handlers.AdminSubtitleHandler
var subtitleManager *subtitles.Manager
if subtitleRepo != nil {
adminSubtitleHandler = handlers.NewAdminSubtitleHandler(subtitleRepo)
}
@@ -721,7 +722,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Build subtitle search handler if we have DB and S3.
var subtitleSearchHandler *handlers.SubtitleSearchHandler
if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil {
subtitleManager := subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
// Load provider configs from DB and register enabled providers.
providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext)
@@ -755,6 +756,10 @@ func NewRouter(deps Dependencies) chi.Router {
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
}
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager)
}
// Build section handler if DB is available.
var sectionHandler *handlers.SectionHandler
var sectionSettingsHandler *handlers.SectionSettingsHandler
@@ -1512,9 +1517,18 @@ func NewRouter(deps Dependencies) chi.Router {
// Subtitle search routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
subtitleSearchHandler.FileAuthorizer = &handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
}
}
r.Route("/subtitles", func(r chi.Router) {
r.Post("/search", subtitleSearchHandler.HandleSearch)
r.Post("/download", subtitleSearchHandler.HandleDownload)
r.Post("/upload", subtitleSearchHandler.HandleUpload)
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
})
@@ -1888,6 +1902,14 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/test", adminSubtitleHandler.HandleTestProvider)
})
})
r.Route("/subtitles", func(r chi.Router) {
r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles)
r.Route("/{id}", func(r chi.Router) {
r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle)
r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle)
r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle)
})
})
}
// Rate limit admin routes
@@ -33,6 +33,10 @@ func (r fakeSubtitleRepository) ListDownloadedSubtitles(_ context.Context, media
return r.downloaded[mediaFileID], nil
}
func (r fakeSubtitleRepository) UpdateDownloadedSubtitle(context.Context, int, subtitles.SubtitleMetadataUpdate) (*subtitles.DownloadedSubtitle, error) {
panic("unused")
}
func (r fakeSubtitleRepository) DeleteDownloadedSubtitle(context.Context, int) (*subtitles.DownloadedSubtitle, error) {
panic("unused")
}
+50
View File
@@ -0,0 +1,50 @@
package subtitles
import (
"fmt"
"path/filepath"
"strings"
)
const (
// ProviderUpload identifies user-uploaded subtitles in downloaded_subtitles.
ProviderUpload = "upload"
// MaxUploadSize is the maximum allowed size for user-uploaded subtitle files.
MaxUploadSize = 5 << 20 // 5 MB
)
var allowedUploadFormats = map[string]SubtitleFormat{
"srt": FormatSRT,
"vtt": FormatVTT,
"ass": FormatASS,
"ssa": FormatSSA,
"sub": FormatSUB,
}
// SubtitleContentType returns the HTTP content type for a subtitle format.
func SubtitleContentType(format SubtitleFormat) string {
switch format {
case FormatVTT:
return "text/vtt; charset=utf-8"
case FormatSRT:
return "application/x-subrip; charset=utf-8"
case FormatASS, FormatSSA:
return "text/x-ssa; charset=utf-8"
default:
return "application/octet-stream"
}
}
// FormatFromFilename returns the subtitle format from a filename extension.
func FormatFromFilename(name string) (SubtitleFormat, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if ext == "" {
return "", fmt.Errorf("missing file extension")
}
format, ok := allowedUploadFormats[ext]
if !ok {
return "", fmt.Errorf("unsupported subtitle format: %s", ext)
}
return format, nil
}
+382
View File
@@ -0,0 +1,382 @@
package subtitles
import (
"bufio"
"bytes"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/abadojack/whatlanggo"
"golang.org/x/text/language"
"github.com/Silo-Server/silo-server/internal/lang"
)
// LanguageDetectionSource describes where an upload language was resolved from.
type LanguageDetectionSource string
const (
LanguageSourceFilename LanguageDetectionSource = "filename"
LanguageSourceMetadata LanguageDetectionSource = "metadata"
LanguageSourceContent LanguageDetectionSource = "content"
LanguageSourceManual LanguageDetectionSource = "manual"
)
// LanguageDetection holds a resolved subtitle language and its origin.
type LanguageDetection struct {
Language string `json:"language"`
Source LanguageDetectionSource `json:"source"`
}
var (
subtitleTimestampLine = regexp.MustCompile(`^\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}`)
assDialoguePrefix = regexp.MustCompile(`(?i)^dialogue:\s*\d`)
metadataLanguageLine = regexp.MustCompile(`(?i)^(?:language|lang)\s*:\s*(.+)$`)
vttLanguageLine = regexp.MustCompile(`(?i)language\s*:\s*([^;]+)`)
)
var filenameLanguageSkipTokens = map[string]struct{}{
"forced": {}, "sdh": {}, "hi": {}, "cc": {}, "sub": {}, "subs": {},
"subtitle": {}, "subtitles": {}, "caption": {}, "captions": {},
"the": {}, "and": {}, "for": {}, "with": {},
}
var filenameLanguageReleaseTokens = map[string]struct{}{
"webrip": {}, "webdl": {}, "web": {}, "bluray": {}, "bdrip": {}, "dvdrip": {},
"hdtv": {}, "hdrip": {}, "remux": {}, "proper": {}, "repack": {}, "extended": {},
"unrated": {}, "dts": {}, "aac": {}, "ac3": {}, "eac3": {}, "truehd": {},
"atmos": {}, "x264": {}, "x265": {}, "h264": {}, "h265": {}, "hevc": {}, "avc": {},
"720p": {}, "1080p": {}, "2160p": {}, "4k": {}, "8k": {}, "hdr": {}, "sdr": {},
}
// filenameLanguageAliases maps common subtitle release abbreviations to ISO codes.
var filenameLanguageAliases = map[string]string{
"chs": "zh", "cht": "zh", "chi": "zh", "zho": "zh", "cn": "zh",
"eng": "en", "jpn": "ja", "ger": "de", "deu": "de", "fre": "fr", "fra": "fr",
"spa": "es", "esp": "es", "ita": "it", "por": "pt", "pob": "pt", "br": "pt",
"rus": "ru", "pol": "pl", "cze": "cs", "ces": "cs", "dan": "da", "dut": "nl",
"nld": "nl", "swe": "sv", "nor": "no", "fin": "fi", "gre": "el", "ell": "el",
"rum": "ro", "ron": "ro", "hrv": "hr", "srp": "sr", "bul": "bg", "ukr": "uk",
"vie": "vi", "ind": "id", "msa": "ms", "may": "ms", "heb": "he", "hin": "hi",
"kor": "ko", "ara": "ar", "tha": "th", "tur": "tr", "hun": "hu", "slo": "sk",
"slk": "sk", "slv": "sl", "est": "et", "lav": "lv", "lit": "lt", "ice": "is",
"isl": "is", "wel": "cy", "cym": "cy", "cat": "ca", "eus": "eu", "baq": "eu",
}
var metadataLanguageNames = map[string]string{
"english": "en", "spanish": "es", "french": "fr", "german": "de", "italian": "it",
"portuguese": "pt", "japanese": "ja", "korean": "ko", "chinese": "zh", "russian": "ru",
"arabic": "ar", "dutch": "nl", "polish": "pl", "swedish": "sv", "norwegian": "no",
"danish": "da", "finnish": "fi", "greek": "el", "turkish": "tr", "hungarian": "hu",
"czech": "cs", "romanian": "ro", "hebrew": "he", "hindi": "hi", "thai": "th",
"vietnamese": "vi", "indonesian": "id", "ukrainian": "uk",
}
// DetectSubtitleLanguage resolves a subtitle language from filename, embedded
// metadata, or dialogue text.
func DetectSubtitleLanguage(filename string, format SubtitleFormat, data []byte) LanguageDetection {
if language, ok := languageFromFilename(filename); ok {
return LanguageDetection{Language: language, Source: LanguageSourceFilename}
}
if language, ok := languageFromMetadata(format, data); ok {
return LanguageDetection{Language: language, Source: LanguageSourceMetadata}
}
if language, ok := languageFromContent(data, format); ok {
return LanguageDetection{Language: language, Source: LanguageSourceContent}
}
return LanguageDetection{}
}
// ResolveUploadLanguage prefers auto-detected language and falls back to the
// user-provided hint when detection fails. When preferUserLanguage is true, the
// user-provided language is used as an explicit override.
func ResolveUploadLanguage(filename string, format SubtitleFormat, data []byte, userLanguage string, preferUserLanguage bool) (LanguageDetection, error) {
if preferUserLanguage {
if manual := canonicalLanguageToken(userLanguage); manual != "" {
return LanguageDetection{Language: manual, Source: LanguageSourceManual}, nil
}
return LanguageDetection{}, fmt.Errorf("invalid subtitle language")
}
if detected := DetectSubtitleLanguage(filename, format, data); detected.Language != "" {
return detected, nil
}
if manual := canonicalLanguageToken(userLanguage); manual != "" {
return LanguageDetection{Language: manual, Source: LanguageSourceManual}, nil
}
return LanguageDetection{}, fmt.Errorf("could not detect subtitle language")
}
func languageFromFilename(filename string) (string, bool) {
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
tokens := splitFilenameTokens(base)
for i := len(tokens) - 1; i >= 0; i-- {
token := strings.Trim(tokens[i], "[](){}")
if token == "" {
continue
}
lower := strings.ToLower(token)
if _, skip := filenameLanguageSkipTokens[lower]; skip {
continue
}
if _, skip := filenameLanguageReleaseTokens[lower]; skip {
continue
}
if containsDigit(token) {
continue
}
if language, ok := filenameLanguageToken(token); ok {
return language, true
}
}
return "", false
}
func filenameLanguageToken(token string) (string, bool) {
trimmed := strings.TrimSpace(token)
if len(trimmed) < 2 || len(trimmed) > 3 {
return "", false
}
for _, r := range trimmed {
if r < 'A' || r > 'Z' {
if r < 'a' || r > 'z' {
return "", false
}
}
}
lower := strings.ToLower(trimmed)
if mapped, ok := filenameLanguageAliases[lower]; ok {
return mapped, true
}
language := canonicalLanguageToken(trimmed)
if language == "" {
return "", false
}
return language, true
}
func containsDigit(value string) bool {
for _, r := range value {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
func languageFromMetadata(format SubtitleFormat, data []byte) (string, bool) {
switch format {
case FormatASS, FormatSSA:
return languageFromASSMetadata(data)
case FormatVTT:
return languageFromVTTMetadata(data)
default:
return "", false
}
}
func languageFromASSMetadata(data []byte) (string, bool) {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(strings.ToLower(line), "[") {
continue
}
if matches := metadataLanguageLine.FindStringSubmatch(line); len(matches) == 2 {
if language, ok := languageFromMetadataValue(matches[1]); ok {
return language, true
}
}
}
return "", false
}
func languageFromVTTMetadata(data []byte) (string, bool) {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.EqualFold(line, "WEBVTT") {
continue
}
if matches := vttLanguageLine.FindStringSubmatch(line); len(matches) == 2 {
if language, ok := languageFromMetadataValue(matches[1]); ok {
return language, true
}
}
if strings.Contains(line, "-->") {
break
}
}
return "", false
}
func languageFromContent(data []byte, format SubtitleFormat) (string, bool) {
text := extractSubtitleDialogue(data, format)
if len([]rune(text)) < 40 {
return "", false
}
info := whatlanggo.Detect(text)
if !info.IsReliable() {
return "", false
}
code := info.Lang.Iso6391()
if code == "" {
code = info.Lang.Iso6393()
}
if language := canonicalLanguageToken(code); language != "" {
return language, true
}
return "", false
}
func extractSubtitleDialogue(data []byte, format SubtitleFormat) string {
switch format {
case FormatASS, FormatSSA:
return extractASSDialogue(data)
case FormatVTT:
return extractVTTDialogue(data)
default:
return extractSRTDialogue(data)
}
}
func extractSRTDialogue(data []byte) string {
var b strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || subtitleTimestampLine.MatchString(line) {
continue
}
if _, err := fmt.Sscanf(line, "%d", new(int)); err == nil {
continue
}
appendDialogueLine(&b, line)
}
return b.String()
}
func extractVTTDialogue(data []byte) string {
var b strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(data))
inCue := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
inCue = false
continue
}
if strings.EqualFold(line, "WEBVTT") || strings.HasPrefix(strings.ToUpper(line), "NOTE") {
continue
}
if strings.Contains(line, "-->") {
inCue = true
continue
}
if inCue {
appendDialogueLine(&b, line)
}
}
return b.String()
}
func extractASSDialogue(data []byte) string {
var b strings.Builder
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !assDialoguePrefix.MatchString(line) {
continue
}
parts := strings.SplitN(line, ",", 10)
if len(parts) < 10 {
continue
}
appendDialogueLine(&b, strings.TrimSpace(parts[9]))
}
return b.String()
}
func appendDialogueLine(b *strings.Builder, line string) {
cleaned := strings.TrimSpace(stripASSTags(line))
if cleaned == "" {
return
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(cleaned)
}
func stripASSTags(line string) string {
var b strings.Builder
b.Grow(len(line))
inTag := false
for _, r := range line {
switch {
case r == '{':
inTag = true
case r == '}':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return b.String()
}
func splitFilenameTokens(base string) []string {
replaced := strings.NewReplacer("_", ".", "-", ".", " ", ".").Replace(base)
raw := strings.Split(replaced, ".")
tokens := make([]string, 0, len(raw))
for _, token := range raw {
token = strings.TrimSpace(token)
if token != "" {
tokens = append(tokens, token)
}
}
return tokens
}
func languageFromMetadataValue(value string) (string, bool) {
if language := canonicalLanguageToken(value); language != "" {
return language, true
}
if mapped, ok := metadataLanguageNames[strings.ToLower(strings.TrimSpace(value))]; ok {
return mapped, true
}
return "", false
}
// NormalizeLanguageCode canonicalizes a subtitle language code to ISO 639-1 base form.
func NormalizeLanguageCode(value string) (string, error) {
language := canonicalLanguageToken(value)
if language == "" {
return "", fmt.Errorf("invalid subtitle language")
}
return language, nil
}
func canonicalLanguageToken(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
candidate := lang.Canonical(trimmed)
tag, err := language.Parse(candidate)
if err != nil {
return ""
}
base, conf := tag.Base()
if conf == language.No {
return ""
}
return strings.ToLower(base.String())
}
+119
View File
@@ -0,0 +1,119 @@
package subtitles
import "testing"
func TestDetectSubtitleLanguageFromReleaseFilename(t *testing.T) {
cases := []struct {
filename string
want string
}{
{
filename: "The.Super.Mario.Galaxy.Movie.2026.720p.WEBRip.x264.AAC-[YTS.BZ]-TR.srt",
want: "tr",
},
{
filename: "Dune.Part.Two.2024.1080p.BluRay.x265.DTS-HD.MA.5.1-EN.srt",
want: "en",
},
{
filename: "some.movie.chs.srt",
want: "zh",
},
}
for _, tc := range cases {
detected := DetectSubtitleLanguage(tc.filename, FormatSRT, nil)
if detected.Language != tc.want {
t.Fatalf("filename %q: language = %q, source = %q, want %q", tc.filename, detected.Language, detected.Source, tc.want)
}
if detected.Source != LanguageSourceFilename {
t.Fatalf("filename %q: source = %q, want filename", tc.filename, detected.Source)
}
}
}
func TestDetectSubtitleLanguageFromFilename(t *testing.T) {
detected := DetectSubtitleLanguage("Movie.en.srt", FormatSRT, []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"))
if detected.Language != "en" {
t.Fatalf("language = %q, want en", detected.Language)
}
if detected.Source != LanguageSourceFilename {
t.Fatalf("source = %q, want filename", detected.Source)
}
}
func TestDetectSubtitleLanguageFromASSMetadata(t *testing.T) {
data := []byte(`[Script Info]
Title: Example
Language: Spanish
[Events]
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hola
`)
detected := DetectSubtitleLanguage("subtitle.ass", FormatASS, data)
if detected.Language != "es" {
t.Fatalf("language = %q, want es", detected.Language)
}
if detected.Source != LanguageSourceMetadata {
t.Fatalf("source = %q, want metadata", detected.Source)
}
}
func TestDetectSubtitleLanguageFromContent(t *testing.T) {
data := []byte(`1
00:00:01,000 --> 00:00:04,000
Bonjour tout le monde, comment allez-vous aujourd'hui?
2
00:00:05,000 --> 00:00:08,000
Je suis tres heureux de vous voir ici ce soir.
`)
detected := DetectSubtitleLanguage("subtitle.srt", FormatSRT, data)
if detected.Language != "fr" {
t.Fatalf("language = %q, want fr", detected.Language)
}
if detected.Source != LanguageSourceContent {
t.Fatalf("source = %q, want content", detected.Source)
}
}
func TestResolveUploadLanguageUsesManualFallback(t *testing.T) {
detected, err := ResolveUploadLanguage("subtitle.srt", FormatSRT, []byte("hello"), "de", false)
if err != nil {
t.Fatalf("ResolveUploadLanguage() error = %v", err)
}
if detected.Language != "de" {
t.Fatalf("language = %q, want de", detected.Language)
}
if detected.Source != LanguageSourceManual {
t.Fatalf("source = %q, want manual", detected.Source)
}
}
func TestResolveUploadLanguagePrefersFilename(t *testing.T) {
detected, err := ResolveUploadLanguage("movie.ja.srt", FormatSRT, []byte("hello"), "de", false)
if err != nil {
t.Fatalf("ResolveUploadLanguage() error = %v", err)
}
if detected.Language != "ja" {
t.Fatalf("language = %q, want ja", detected.Language)
}
}
func TestManagerUploadDetectsLanguageFromFilename(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(t.Context(), UploadRequest{
MediaFileID: 42,
Filename: "custom.fr.srt",
Data: data,
})
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
if sub.Language != "fr" {
t.Fatalf("language = %q, want fr", sub.Language)
}
}
+194 -6
View File
@@ -4,12 +4,21 @@ 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
@@ -121,6 +130,31 @@ type DownloadRequest struct {
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()
@@ -135,10 +169,72 @@ func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*Downloade
return nil, fmt.Errorf("download from %s: %w", req.ProviderName, err)
}
hash := fmt.Sprintf("%x", sha256.Sum256(data))[:8]
s3Key := fmt.Sprintf("subtitles/%d/%s_%s_%s.%s", req.MediaFileID, req.Language, req.ProviderName, hash, format)
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)
// Check for duplicate
existing, err := m.repo.GetDownloadedSubtitleByS3Key(ctx, s3Key)
if err != nil {
return nil, fmt.Errorf("check duplicate: %w", err)
@@ -147,15 +243,15 @@ func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*Downloade
return existing, nil
}
if err := m.s3.PutObject(ctx, m.s3Bucket, s3Key, data); err != 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.ProviderName,
Provider: req.Provider,
Language: req.Language,
Format: format,
Format: req.Format,
ReleaseName: req.ReleaseName,
S3Key: s3Key,
Score: req.Score,
@@ -170,6 +266,98 @@ func (m *Manager) Download(ctx context.Context, req DownloadRequest) (*Downloade
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)
+209
View File
@@ -0,0 +1,209 @@
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 &copy, 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 &copy, 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
}
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")
}
}
+21
View File
@@ -73,6 +73,27 @@ func (r *PgRepository) ListDownloadedSubtitles(ctx context.Context, mediaFileID
return subs, rows.Err()
}
func (r *PgRepository) UpdateDownloadedSubtitle(ctx context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error) {
var sub DownloadedSubtitle
err := r.pool.QueryRow(ctx,
`UPDATE downloaded_subtitles
SET language = $1, release_name = $2, hearing_impaired = $3, s3_key = $4
WHERE id = $5
RETURNING id, media_file_id, provider, language, format, release_name,
s3_key, score, hearing_impaired, downloaded_by, created_at`,
update.Language, update.ReleaseName, update.HearingImpaired, update.S3Key, id,
).Scan(&sub.ID, &sub.MediaFileID, &sub.Provider, &sub.Language, &sub.Format,
&sub.ReleaseName, &sub.S3Key, &sub.Score, &sub.HearingImpaired,
&sub.DownloadedBy, &sub.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("update downloaded subtitle: %w", err)
}
return &sub, nil
}
func (r *PgRepository) DeleteDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error) {
var sub DownloadedSubtitle
err := r.pool.QueryRow(ctx,
+9
View File
@@ -3,11 +3,20 @@ package subtitles
import "context"
// SubtitleMetadataUpdate contains mutable fields for a downloaded subtitle record.
type SubtitleMetadataUpdate struct {
Language string
ReleaseName string
HearingImpaired bool
S3Key string
}
// Repository defines database operations for subtitle management.
type Repository interface {
InsertDownloadedSubtitle(ctx context.Context, sub *DownloadedSubtitle) error
GetDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error)
ListDownloadedSubtitles(ctx context.Context, mediaFileID int) ([]DownloadedSubtitle, error)
UpdateDownloadedSubtitle(ctx context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error)
DeleteDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error)
GetDownloadedSubtitleByS3Key(ctx context.Context, s3Key string) (*DownloadedSubtitle, error)
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_downloaded_subtitles_provider_created;
DROP INDEX IF EXISTS idx_downloaded_subtitles_created;
@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_downloaded_subtitles_created
ON downloaded_subtitles (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_downloaded_subtitles_provider_created
ON downloaded_subtitles (provider, created_at DESC);
+2
View File
@@ -50,6 +50,7 @@ import AdminCollectionEditor from "@/pages/AdminCollectionEditor";
import AdminPlaybackHistory from "@/pages/AdminPlaybackHistory";
import AdminMaintenance from "@/pages/AdminMaintenance";
import AdminApiKeys from "@/pages/AdminApiKeys";
import AdminSubtitles from "@/pages/AdminSubtitles";
import AdminUserDetail from "@/pages/AdminUserDetail";
import AdminTasks from "@/pages/AdminTasks";
import AdminTaskDetail from "@/pages/AdminTaskDetail";
@@ -383,6 +384,7 @@ function AppRoutes() {
<Route path="settings" element={<AdminSettingsLayout />} />
<Route path="recommendations" element={<AdminRecommendations />} />
<Route path="api-keys" element={<AdminApiKeys />} />
<Route path="subtitles" element={<AdminSubtitles />} />
<Route path="tasks" element={<AdminTasks />} />
<Route path="tasks/:key" element={<AdminTaskDetail />} />
<Route path="stats" element={<Navigate to="/admin" replace />} />
+59
View File
@@ -370,6 +370,65 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
return JSON.parse(text) as T;
}
function buildApiHeaders(options: RequestInit = {}): Record<string, string> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
}
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const profileId = getProfileId();
if (profileId) {
headers["X-Profile-Id"] = profileId;
}
const profToken = getProfileToken();
if (profToken) {
headers["X-Profile-Token"] = profToken;
}
Object.assign(headers, getDeviceHeaders());
return headers;
}
/** Downloads a binary API response and triggers a browser file save. */
export async function apiDownload(
path: string,
filename: string,
options: RequestInit = {},
): Promise<void> {
let headers = buildApiHeaders(options);
let res = await fetch(`/api/v1${path}`, { ...options, headers });
if (res.status === 401 && getRefreshToken()) {
if (!refreshPromise) {
refreshPromise = attemptRefresh().finally(() => {
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
headers = buildApiHeaders(options);
headers["Authorization"] = `Bearer ${accessToken}`;
res = await fetch(`/api/v1${path}`, { ...options, headers });
}
}
if (!res.ok) {
const apiErr = await parseApiError(res);
throw new ApiClientError(res.status, apiErr.error, apiErr.message, apiErr);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
// People API
export async function searchPeople(query: string, limit = 20): Promise<import("./types").Person[]> {
const params = new URLSearchParams({ q: query, limit: String(limit) });
+55
View File
@@ -2949,6 +2949,20 @@ export interface SubtitleDownloadRequest {
hearing_impaired: boolean;
}
export interface SubtitleUploadRequest {
media_file_id: number;
file: File;
language?: string;
language_override?: boolean;
release_name?: string;
hearing_impaired?: boolean;
}
export interface SubtitleLanguageDetection {
language: string;
source: "filename" | "metadata" | "content" | "manual";
}
export interface DownloadedSubtitle {
id: number;
media_file_id: number;
@@ -2961,6 +2975,47 @@ export interface DownloadedSubtitle {
created_at: string;
}
export interface AdminDownloadedSubtitle {
id: number;
media_file_id: number;
media_content_id?: string;
provider: string;
language: string;
format: string;
release_name: string;
score: number;
hearing_impaired: boolean;
created_at: string;
downloaded_by?: number;
uploader_username: string;
media_title: string;
media_type: string;
file_path: string;
}
export interface AdminDownloadedSubtitlesResponse {
subtitles: AdminDownloadedSubtitle[];
total: number;
uploads: number;
provider_downloads: number;
}
export interface AdminDownloadedSubtitlesFilters {
provider?: string;
language?: string;
userId?: number;
mediaFileId?: number;
q?: string;
limit?: number;
offset?: number;
}
export interface AdminUpdateDownloadedSubtitleRequest {
language?: string;
release_name?: string;
hearing_impaired?: boolean;
}
export interface SubtitleProviderConfig {
provider_name: string;
enabled: boolean;
+22
View File
@@ -1643,6 +1643,28 @@
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.caption-empty-state span {
display: block;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 18%, transparent);
}
.caption-empty-state span:nth-child(1) {
width: 72%;
margin-inline: auto;
}
.caption-empty-state span:nth-child(2) {
width: 54%;
margin-inline: auto;
}
.caption-empty-state span:nth-child(3) {
width: 38%;
margin-inline: auto;
}
/* ── Scan Queue Accent ──────────────────────────────────── */
/* Animated gradient sweep across the top of the scan queue
to signal live activity. Only visible when scans are active. */
+6
View File
@@ -8,6 +8,7 @@ import {
Users,
MonitorSmartphone,
History,
Captions,
Download,
SlidersHorizontal,
Server,
@@ -114,6 +115,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
href: "/admin/sections",
},
{
label: "Subtitles",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/subtitles",
},
],
},
{
@@ -0,0 +1,171 @@
import { useState } from "react";
import type { AdminDownloadedSubtitle } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { useAdminUpdateDownloadedSubtitle } from "@/hooks/queries/admin/subtitles";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
import { cn } from "@/lib/utils";
import { languageChipClass, providerBadgeClass, providerLabel } from "./subtitleAdminStyles";
interface AdminSubtitleEditSheetProps {
subtitle: AdminDownloadedSubtitle | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AdminSubtitleEditSheet({
subtitle,
open,
onOpenChange,
}: AdminSubtitleEditSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col sm:max-w-md">
{subtitle ? (
<AdminSubtitleEditForm
key={subtitle.id}
subtitle={subtitle}
onClose={() => onOpenChange(false)}
/>
) : (
<>
<SheetHeader>
<SheetTitle>Edit subtitle</SheetTitle>
</SheetHeader>
</>
)}
</SheetContent>
</Sheet>
);
}
function AdminSubtitleEditForm({
subtitle,
onClose,
}: {
subtitle: AdminDownloadedSubtitle;
onClose: () => void;
}) {
const updateMutation = useAdminUpdateDownloadedSubtitle();
const [language, setLanguage] = useState(subtitle.language);
const [releaseName, setReleaseName] = useState(subtitle.release_name);
const [hearingImpaired, setHearingImpaired] = useState(subtitle.hearing_impaired);
const languageChanged = language !== subtitle.language;
async function handleSave() {
await updateMutation.mutateAsync({
id: subtitle.id,
patch: {
language,
release_name: releaseName,
hearing_impaired: hearingImpaired,
},
});
onClose();
}
return (
<>
<SheetHeader>
<SheetTitle>Edit subtitle</SheetTitle>
<SheetDescription>
Update stored metadata for this subtitle record. File content is not replaced.
</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-1 py-2">
<div className="surface-panel-subtle space-y-3 rounded-xl px-4 py-4">
<div className="text-sm font-semibold">{subtitle.media_title || "Unknown media"}</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
languageChipClass(),
)}
>
{subtitle.language.toUpperCase()} · {getLanguageName(subtitle.language)}
</span>
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
providerBadgeClass(subtitle.provider),
)}
>
{providerLabel(subtitle.provider)}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-language">Language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger id="subtitle-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.code.toUpperCase()} · {lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
{languageChanged && (
<p className="text-muted-foreground text-xs leading-relaxed">
Updates stored language label; file content unchanged.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-release-name">Release name</Label>
<Input
id="subtitle-release-name"
value={releaseName}
onChange={(event) => setReleaseName(event.target.value)}
className="font-mono text-xs"
/>
</div>
<div className="border-border/60 flex items-center justify-between rounded-xl border px-4 py-3">
<div className="space-y-1">
<Label htmlFor="subtitle-hearing-impaired">Hearing impaired</Label>
<p className="text-muted-foreground text-xs">Marks this track as SDH/CC.</p>
</div>
<Switch
id="subtitle-hearing-impaired"
checked={hearingImpaired}
onCheckedChange={setHearingImpaired}
/>
</div>
</div>
<SheetFooter className="mt-auto gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" disabled={updateMutation.isPending} onClick={() => void handleSave()}>
Save changes
</Button>
</SheetFooter>
</>
);
}
@@ -0,0 +1,118 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LANGUAGES } from "@/player/utils/languageNames";
import { SUBTITLE_PROVIDER_OPTIONS } from "./subtitleAdminStyles";
const ALL = "all";
interface AdminSubtitlesFiltersProps {
provider: string;
language: string;
userId: string;
search: string;
users: Array<{ id: number; username: string }>;
onProviderChange: (value: string) => void;
onLanguageChange: (value: string) => void;
onUserChange: (value: string) => void;
onSearchChange: (value: string) => void;
onReset: () => void;
}
export default function AdminSubtitlesFilters({
provider,
language,
userId,
search,
users,
onProviderChange,
onLanguageChange,
onUserChange,
onSearchChange,
onReset,
}: AdminSubtitlesFiltersProps) {
return (
<div className="surface-panel rounded-2xl border-0 px-3 py-3 sm:px-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0 flex-1">
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search release name…"
className="font-mono text-xs sm:max-w-sm"
aria-label="Search subtitle release name"
/>
</div>
<div
className="flex flex-wrap items-center gap-1.5"
role="group"
aria-label="Filter by subtitle provider"
>
{SUBTITLE_PROVIDER_OPTIONS.map((option) => {
const active = provider === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={active}
onClick={() => onProviderChange(option.value)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
active
? "border-primary/40 bg-primary/15 text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.06)]"
: "border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={language} onValueChange={onLanguageChange}>
<SelectTrigger className="w-[180px]" aria-label="Filter by language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All languages</SelectItem>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={userId} onValueChange={onUserChange}>
<SelectTrigger className="w-[200px]" aria-label="Filter by uploader">
<SelectValue placeholder="Uploader" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All uploaders</SelectItem>
{users.map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{user.username}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="sm" onClick={onReset}>
Reset filters
</Button>
</div>
</div>
</div>
);
}
export { ALL as FILTER_ALL };
@@ -0,0 +1,272 @@
import { useState } from "react";
import { Link } from "react-router";
import type { AdminDownloadedSubtitle } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { downloadAdminSubtitle } from "@/hooks/queries/admin/subtitles";
import { getLanguageName } from "@/player/utils/languageNames";
import { cn } from "@/lib/utils";
import { Download, Ear, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import AdminSubtitleEditSheet from "./AdminSubtitleEditSheet";
import {
basenameFromPath,
formatChipClass,
languageChipClass,
providerBadgeClass,
providerLabel,
staggerRowClass,
} from "./subtitleAdminStyles";
interface AdminSubtitlesTableProps {
subtitles: AdminDownloadedSubtitle[];
hasActiveFilters: boolean;
onResetFilters: () => void;
onDelete: (subtitle: AdminDownloadedSubtitle) => void;
isDeleting: boolean;
}
function formatRelative(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const deltaMs = Date.now() - date.getTime();
const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString();
}
export default function AdminSubtitlesTable({
subtitles,
hasActiveFilters,
onResetFilters,
onDelete,
isDeleting,
}: AdminSubtitlesTableProps) {
const [editTarget, setEditTarget] = useState<AdminDownloadedSubtitle | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminDownloadedSubtitle | null>(null);
const [downloadingId, setDownloadingId] = useState<number | null>(null);
async function handleDownload(subtitle: AdminDownloadedSubtitle) {
setDownloadingId(subtitle.id);
try {
await downloadAdminSubtitle(subtitle);
toast.success("Subtitle downloaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download subtitle");
} finally {
setDownloadingId(null);
}
}
if (subtitles.length === 0) {
return (
<div className="surface-panel rounded-2xl border-0 px-6 py-16 text-center">
<div className="caption-empty-state mx-auto mb-5 max-w-md space-y-1.5">
<span />
<span />
<span />
</div>
<h2 className="text-lg font-semibold tracking-tight">
{hasActiveFilters ? "No subtitles match these filters" : "No stored subtitles yet"}
</h2>
<p className="text-muted-foreground mx-auto mt-2 max-w-lg text-sm leading-relaxed">
{hasActiveFilters
? "Try widening the provider, language, or uploader filters to see more results."
: "User uploads and provider downloads will appear here once subtitles are stored in S3."}
</p>
{hasActiveFilters && (
<Button type="button" variant="outline" className="mt-5" onClick={onResetFilters}>
Reset filters
</Button>
)}
</div>
);
}
return (
<>
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Media</TableHead>
<TableHead>File</TableHead>
<TableHead>Language</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Release</TableHead>
<TableHead>Format</TableHead>
<TableHead className="w-10">HI</TableHead>
<TableHead>Uploader</TableHead>
<TableHead>Added</TableHead>
<TableHead className="w-[120px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subtitles.map((subtitle, index) => (
<TableRow key={subtitle.id} className={cn("group", staggerRowClass(index))}>
<TableCell className="max-w-[220px]">
<div className="space-y-1">
{subtitle.media_content_id ? (
<Link
to={`/item/${encodeURIComponent(subtitle.media_content_id)}`}
className="hover:text-primary line-clamp-2 font-semibold transition-colors hover:underline"
>
{subtitle.media_title || subtitle.media_content_id}
</Link>
) : (
<div className="line-clamp-2 font-semibold">
{subtitle.media_title || "Unknown media"}
</div>
)}
{subtitle.media_type === "episode" && (
<Badge variant="outline" className="text-[10px] tracking-[0.12em] uppercase">
Episode
</Badge>
)}
</div>
</TableCell>
<TableCell
className="text-muted-foreground max-w-[180px] truncate font-mono text-xs"
title={subtitle.file_path}
>
{basenameFromPath(subtitle.file_path)}
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
languageChipClass(),
)}
>
<span className="font-semibold tracking-[0.08em] uppercase">
{subtitle.language}
</span>
<span className="text-muted-foreground hidden sm:inline">
{getLanguageName(subtitle.language)}
</span>
</span>
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
providerBadgeClass(subtitle.provider),
)}
>
{providerLabel(subtitle.provider)}
</span>
</TableCell>
<TableCell
className="max-w-[200px] truncate font-mono text-xs"
title={subtitle.release_name}
>
{subtitle.release_name || "—"}
</TableCell>
<TableCell>
<span className={cn("inline-flex rounded px-2 py-0.5", formatChipClass())}>
.{subtitle.format}
</span>
</TableCell>
<TableCell>
{subtitle.hearing_impaired ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-200">
<Ear className="h-3.5 w-3.5" aria-hidden="true" />
HI
</span>
) : null}
</TableCell>
<TableCell className="text-sm">{subtitle.uploader_username || "—"}</TableCell>
<TableCell className="text-muted-foreground text-sm" title={subtitle.created_at}>
{formatRelative(subtitle.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Edit subtitle ${subtitle.id}`}
onClick={() => setEditTarget(subtitle)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Download subtitle ${subtitle.id}`}
disabled={downloadingId === subtitle.id}
onClick={() => void handleDownload(subtitle)}
>
{downloadingId === subtitle.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
aria-label={`Delete subtitle ${subtitle.id}`}
onClick={() => setDeleteTarget(subtitle)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AdminSubtitleEditSheet
subtitle={editTarget}
open={editTarget != null}
onOpenChange={(open) => {
if (!open) setEditTarget(null);
}}
/>
<ConfirmDialog
open={deleteTarget != null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="Delete subtitle?"
description={
deleteTarget
? `Remove ${providerLabel(deleteTarget.provider)} ${deleteTarget.language.toUpperCase()} subtitles for "${deleteTarget.media_title || "this media"}"? This deletes the stored file from S3.`
: ""
}
confirmLabel="Delete"
variant="destructive"
isPending={isDeleting}
onConfirm={() => {
if (deleteTarget) {
onDelete(deleteTarget);
setDeleteTarget(null);
}
}}
/>
</>
);
}
@@ -0,0 +1,57 @@
import { cn } from "@/lib/utils";
export const SUBTITLE_PROVIDER_OPTIONS = [
{ value: "all", label: "All" },
{ value: "upload", label: "Upload" },
{ value: "opensubtitles", label: "OpenSubtitles" },
{ value: "subdl", label: "SubDL" },
{ value: "subsource", label: "SubSource" },
] as const;
export function providerBadgeClass(provider: string): string {
switch (provider) {
case "upload":
return "border-amber-500/35 bg-amber-500/12 text-amber-100";
case "opensubtitles":
return "border-sky-500/30 bg-sky-500/10 text-sky-100";
case "subdl":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-100";
case "subsource":
return "border-violet-500/30 bg-violet-500/10 text-violet-100";
default:
return "border-border/70 bg-muted/40 text-muted-foreground";
}
}
export function providerLabel(provider: string): string {
return SUBTITLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider;
}
export function languageChipClass(): string {
return "border-primary/25 bg-primary/10 text-foreground";
}
export function formatChipClass(): string {
return "border-border/60 bg-muted/30 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground";
}
export function staggerRowClass(index: number): string {
const capped = Math.min(index, 8);
return cn("motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300", {
"motion-safe:delay-0": capped === 0,
"motion-safe:delay-[40ms]": capped === 1,
"motion-safe:delay-[80ms]": capped === 2,
"motion-safe:delay-[120ms]": capped === 3,
"motion-safe:delay-[160ms]": capped === 4,
"motion-safe:delay-[200ms]": capped === 5,
"motion-safe:delay-[240ms]": capped === 6,
"motion-safe:delay-[280ms]": capped === 7,
"motion-safe:delay-[320ms]": capped >= 8,
});
}
export function basenameFromPath(filePath: string): string {
if (!filePath) return "—";
const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath;
}
@@ -0,0 +1,406 @@
import { useCallback, useRef, useState } from "react";
import { Loader2, Upload } from "lucide-react";
import type { SubtitleLanguageDetection } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
const ACCEPTED_SUBTITLE_EXTENSIONS = ".srt,.vtt,.ass,.ssa,.sub";
const ACCEPTED_SUBTITLE_EXTENSION_LIST = ["srt", "vtt", "ass", "ssa", "sub"] as const;
export interface SubtitleUploadInput {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}
interface SubtitleUploadFormProps {
mediaFileId: number;
upload: (input: SubtitleUploadInput) => Promise<void>;
detectLanguage?: (file: File, fallbackLanguage?: string) => Promise<SubtitleLanguageDetection>;
onSuccess: () => void;
onError?: (message: string) => void;
variant?: "player" | "default";
defaultLanguage?: string;
}
function isAcceptedSubtitleFile(file: File): boolean {
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return ACCEPTED_SUBTITLE_EXTENSION_LIST.includes(
extension as (typeof ACCEPTED_SUBTITLE_EXTENSION_LIST)[number],
);
}
function detectionSourceLabel(source: SubtitleLanguageDetection["source"]): string {
switch (source) {
case "filename":
return "filename";
case "metadata":
return "file metadata";
case "content":
return "subtitle text";
case "manual":
return "manual selection";
default:
return "detection";
}
}
export function SubtitleUploadForm({
mediaFileId,
upload,
detectLanguage,
onSuccess,
onError,
variant = "default",
defaultLanguage = "en",
}: SubtitleUploadFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const detectRequestRef = useRef(0);
const [language, setLanguage] = useState(defaultLanguage);
const [hearingImpaired, setHearingImpaired] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [detectingLanguage, setDetectingLanguage] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [detectionSource, setDetectionSource] = useState<
SubtitleLanguageDetection["source"] | null
>(null);
const [languageOverride, setLanguageOverride] = useState(false);
const [error, setError] = useState<string | null>(null);
const isPlayer = variant === "player";
const reportError = useCallback(
(message: string) => {
setError(message);
onError?.(message);
},
[onError],
);
const runLanguageDetection = useCallback(
async (file: File, fallbackLanguage: string) => {
if (!detectLanguage) {
return;
}
const requestId = ++detectRequestRef.current;
setDetectingLanguage(true);
try {
const result = await detectLanguage(file, fallbackLanguage);
if (requestId !== detectRequestRef.current) {
return;
}
if (result.language) {
setLanguage(result.language);
setDetectionSource(result.source);
setLanguageOverride(false);
}
} catch (err) {
if (requestId !== detectRequestRef.current) {
return;
}
setDetectionSource(null);
reportError(err instanceof Error ? err.message : "Failed to detect subtitle language");
} finally {
if (requestId === detectRequestRef.current) {
setDetectingLanguage(false);
}
}
},
[detectLanguage, reportError],
);
const selectFile = useCallback(
(file: File | null | undefined) => {
if (!file) {
return;
}
if (!isAcceptedSubtitleFile(file)) {
reportError("Unsupported file type. Use SRT, VTT, ASS, SSA, or SUB.");
return;
}
setSelectedFile(file);
setError(null);
void runLanguageDetection(file, language);
},
[language, reportError, runLanguageDetection],
);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
selectFile(event.target.files?.[0]);
};
const handleBrowseClick = () => {
fileInputRef.current?.click();
};
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current += 1;
setIsDragging(true);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDragging(false);
}
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = 0;
setIsDragging(false);
const file = event.dataTransfer.files[0];
selectFile(file);
};
const handleLanguageChange = (value: string) => {
setLanguage(value);
setDetectionSource("manual");
setLanguageOverride(true);
};
const handleUpload = async () => {
if (!selectedFile) {
reportError("Choose a subtitle file to upload");
return;
}
setUploading(true);
setError(null);
try {
await upload({
mediaFileId,
file: selectedFile,
language,
languageOverride,
hearingImpaired,
});
setSelectedFile(null);
setDetectionSource(null);
setLanguageOverride(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onSuccess();
} catch (err) {
reportError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
};
return (
<div
className={cn(
"space-y-3",
isPlayer ? "border-b border-white/10 px-4 py-3" : "rounded-xl border border-dashed p-4",
)}
>
<div className="space-y-1">
<p className={cn("text-sm font-medium", isPlayer ? "text-white" : "text-foreground")}>
Upload subtitle
</p>
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
Drag and drop or browse for SRT, VTT, ASS, SSA, or SUB files up to 5 MB. Language is
detected automatically when possible.
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_SUBTITLE_EXTENSIONS}
className="sr-only"
onChange={handleFileChange}
/>
<div
role="button"
tabIndex={0}
aria-label="Drop subtitle file here or browse"
onClick={handleBrowseClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleBrowseClick();
}
}}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors",
isPlayer
? isDragging
? "border-white/50 bg-white/10"
: "border-white/20 bg-white/5 hover:border-white/35 hover:bg-white/10"
: isDragging
? "border-primary bg-primary/5"
: "border-border/70 bg-muted/20 hover:border-border hover:bg-muted/40",
)}
>
<Upload
className={cn("size-5", isPlayer ? "text-white/70" : "text-muted-foreground")}
aria-hidden="true"
/>
<div className="space-y-1">
<p className={cn("text-sm font-medium", isPlayer ? "text-white" : "text-foreground")}>
{isDragging ? "Drop subtitle file" : "Drag and drop a subtitle file"}
</p>
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
or click to browse
</p>
</div>
</div>
<div className={cn("flex flex-col gap-2", !isPlayer && "sm:flex-row sm:items-center")}>
<div className={cn("space-y-1", !isPlayer && "w-full sm:w-[220px]")}>
{isPlayer ? (
<select
aria-label="Upload language"
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
value={language}
onChange={(event) => handleLanguageChange(event.target.value)}
disabled={detectingLanguage}
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
) : (
<Select
value={language}
onValueChange={handleLanguageChange}
disabled={detectingLanguage}
>
<SelectTrigger>
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{detectingLanguage ? (
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
Detecting language…
</p>
) : detectionSource && detectionSource !== "manual" ? (
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
Detected {getLanguageName(language)} from {detectionSourceLabel(detectionSource)}
</p>
) : null}
</div>
{isPlayer ? (
<label className="flex items-center gap-2 text-xs text-white/70">
<input
type="checkbox"
checked={hearingImpaired}
onChange={(event) => setHearingImpaired(event.target.checked)}
className="rounded border-white/20 bg-neutral-800"
/>
Hearing impaired (HI)
</label>
) : (
<div className="flex items-center gap-2">
<Switch
id={`subtitle-upload-hi-${mediaFileId}`}
checked={hearingImpaired}
onCheckedChange={setHearingImpaired}
/>
<Label htmlFor={`subtitle-upload-hi-${mediaFileId}`} className="text-sm font-normal">
Hearing impaired (HI)
</Label>
</div>
)}
{isPlayer ? (
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded bg-white/10 px-3 py-1.5 text-sm font-medium hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
onClick={handleUpload}
disabled={uploading || detectingLanguage || !selectedFile}
>
{uploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{uploading ? "Uploading…" : "Upload"}
</button>
) : (
<Button
onClick={handleUpload}
disabled={uploading || detectingLanguage || !selectedFile}
className="sm:ml-auto"
>
{uploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{uploading ? "Uploading…" : "Upload"}
</Button>
)}
</div>
{selectedFile && (
<p className={cn("truncate text-xs", isPlayer ? "text-white/60" : "text-muted-foreground")}>
Selected: {selectedFile.name}
</p>
)}
{error && (
<div
role="alert"
className={cn(
"rounded px-3 py-2 text-xs",
isPlayer
? "bg-red-900/40 text-red-300"
: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
)}
>
{error}
</div>
)}
</div>
);
}
+70 -1
View File
@@ -1,6 +1,10 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import { api, apiDownload } from "@/api/client";
import type {
AdminDownloadedSubtitle,
AdminDownloadedSubtitlesFilters,
AdminDownloadedSubtitlesResponse,
AdminUpdateDownloadedSubtitleRequest,
SubtitleProviderConfig,
SubtitleProviderUpdateRequest,
SubtitleProviderTestResponse,
@@ -10,6 +14,71 @@ import { toast } from "sonner";
const ADMIN_STALE_TIME = 30_000;
function buildDownloadedSubtitlesQuery(filters: AdminDownloadedSubtitlesFilters): string {
const params = new URLSearchParams();
if (filters.provider) params.set("provider", filters.provider);
if (filters.language) params.set("language", filters.language);
if (filters.userId != null) params.set("user_id", String(filters.userId));
if (filters.mediaFileId != null) params.set("media_file_id", String(filters.mediaFileId));
if (filters.q) params.set("q", filters.q);
params.set("limit", String(filters.limit ?? 50));
params.set("offset", String(filters.offset ?? 0));
const query = params.toString();
return query ? `/admin/subtitles?${query}` : "/admin/subtitles";
}
export function useAdminDownloadedSubtitles(filters: AdminDownloadedSubtitlesFilters) {
return useQuery({
queryKey: adminKeys.downloadedSubtitles(filters),
queryFn: () =>
api<AdminDownloadedSubtitlesResponse>(buildDownloadedSubtitlesQuery(filters)).then(
(data) => data ?? { subtitles: [], total: 0, uploads: 0, provider_downloads: 0 },
),
staleTime: ADMIN_STALE_TIME,
});
}
export function useAdminUpdateDownloadedSubtitle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: number; patch: AdminUpdateDownloadedSubtitleRequest }) =>
api<{ subtitle: AdminDownloadedSubtitle }>(`/admin/subtitles/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
onSuccess: () => {
toast.success("Subtitle updated");
queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to update subtitle");
},
});
}
export function useAdminDeleteDownloadedSubtitle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
api<void>(`/admin/subtitles/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
toast.success("Subtitle deleted");
queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete subtitle");
},
});
}
export async function downloadAdminSubtitle(subtitle: AdminDownloadedSubtitle): Promise<void> {
const base = subtitle.release_name?.trim() || `subtitle-${subtitle.id}`;
const filename = base.includes(".") ? base : `${base}.${subtitle.format}`;
await apiDownload(`/admin/subtitles/${subtitle.id}/download`, filename);
}
export function useSubtitleProviders() {
return useQuery({
queryKey: adminKeys.subtitleProviders(),
+9
View File
@@ -360,6 +360,15 @@ export const adminKeys = {
operationalLogs: (params: Record<string, unknown>) => ["admin", "logs", "app", params] as const,
auditLogs: (params: Record<string, unknown>) => ["admin", "logs", "audit", params] as const,
subtitleProviders: () => ["admin", "subtitleProviders"] as const,
downloadedSubtitles: (params: {
provider?: string;
language?: string;
userId?: number;
mediaFileId?: number;
q?: string;
limit?: number;
offset?: number;
}) => ["admin", "downloadedSubtitles", params] as const,
historyImportSources: () => ["admin", "historyImportSources"] as const,
historyImportExternalUsers: (sourceId: number) =>
["admin", "historyImportSources", sourceId, "users"] as const,
+70
View File
@@ -5,8 +5,10 @@ import { api } from "@/api/client";
import type {
DownloadedSubtitle,
SubtitleDownloadRequest,
SubtitleLanguageDetection,
SubtitleSearchRequest,
SubtitleSearchResponse,
SubtitleUploadRequest,
} from "@/api/types";
import { subtitleKeys } from "./keys";
@@ -15,6 +17,34 @@ interface DownloadSubtitleResponse {
subtitle: DownloadedSubtitle;
}
function buildSubtitleUploadFormData(request: SubtitleUploadRequest): FormData {
const form = new FormData();
form.set("media_file_id", String(request.media_file_id));
if (request.language) {
form.set("language", request.language);
}
if (request.language_override) {
form.set("language_override", "true");
}
form.set("file", request.file);
if (request.release_name) {
form.set("release_name", request.release_name);
}
if (request.hearing_impaired) {
form.set("hearing_impaired", "true");
}
return form;
}
function buildSubtitleDetectFormData(file: File, language?: string): FormData {
const form = new FormData();
form.set("file", file);
if (language) {
form.set("language", language);
}
return form;
}
export async function fetchDownloadedSubtitles(
mediaFileId: number,
options?: RequestInit,
@@ -48,6 +78,29 @@ export async function downloadSubtitle(
});
}
export async function uploadSubtitle(
request: SubtitleUploadRequest,
options?: RequestInit,
): Promise<DownloadSubtitleResponse> {
return api<DownloadSubtitleResponse>("/subtitles/upload", {
...options,
method: "POST",
body: buildSubtitleUploadFormData(request),
});
}
export async function detectSubtitleLanguage(
file: File,
language?: string,
options?: RequestInit,
): Promise<SubtitleLanguageDetection> {
return api<SubtitleLanguageDetection>("/subtitles/detect-language", {
...options,
method: "POST",
body: buildSubtitleDetectFormData(file, language),
});
}
export function useDownloadedSubtitles(mediaFileId: number | undefined) {
return useQuery({
queryKey: mediaFileId != null ? subtitleKeys.downloaded(mediaFileId) : subtitleKeys.all,
@@ -72,3 +125,20 @@ export function useDownloadSubtitle() {
},
});
}
export function useUploadSubtitle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: SubtitleUploadRequest) => uploadSubtitle(request),
onSuccess: async (_response, request) => {
toast.success("Subtitle uploaded");
await queryClient.invalidateQueries({
queryKey: subtitleKeys.downloaded(request.media_file_id),
});
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to upload subtitle");
},
});
}
+1
View File
@@ -31,6 +31,7 @@ const ADMIN_TITLES: Record<string, string> = {
recommendations: "Admin Recommendations",
requests: "Admin Requests",
sections: "Admin Sections",
subtitles: "Admin Subtitles",
settings: "Admin Settings",
tasks: "Admin Tasks",
users: "Admin Users",
+200
View File
@@ -0,0 +1,200 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { AdminDownloadedSubtitle } from "@/api/types";
import AdminSubtitlesFilters, {
FILTER_ALL,
} from "@/components/admin/subtitles/AdminSubtitlesFilters";
import AdminSubtitlesTable from "@/components/admin/subtitles/AdminSubtitlesTable";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
useAdminDeleteDownloadedSubtitle,
useAdminDownloadedSubtitles,
} from "@/hooks/queries/admin/subtitles";
import { useAdminUsers } from "@/hooks/queries/admin/users";
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
export default function AdminSubtitles() {
const [searchParams, setSearchParams] = useSearchParams();
const { data: users = [] } = useAdminUsers();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(25);
const deleteMutation = useAdminDeleteDownloadedSubtitle();
const provider = searchParams.get("provider") ?? FILTER_ALL;
const language = searchParams.get("language") ?? FILTER_ALL;
const userId = searchParams.get("user_id") ?? FILTER_ALL;
const search = searchParams.get("q") ?? "";
const filters = useMemo(
() => ({
provider: provider !== FILTER_ALL ? provider : undefined,
language: language !== FILTER_ALL ? language : undefined,
userId: userId !== FILTER_ALL ? Number(userId) : undefined,
q: search.trim() || undefined,
limit: pageSize,
offset: page * pageSize,
}),
[language, page, pageSize, provider, search, userId],
);
const subtitlesQuery = useAdminDownloadedSubtitles(filters);
const subtitles = subtitlesQuery.data?.subtitles ?? [];
const total = subtitlesQuery.data?.total ?? 0;
const uploads = subtitlesQuery.data?.uploads ?? 0;
const providerDownloads = subtitlesQuery.data?.provider_downloads ?? 0;
const languageCount = new Set(subtitles.map((row) => row.language)).size;
const hasActiveFilters =
provider !== FILTER_ALL ||
language !== FILTER_ALL ||
userId !== FILTER_ALL ||
search.trim().length > 0;
function updateFilter(key: string, value: string) {
const next = new URLSearchParams(searchParams);
if (value === FILTER_ALL || value.trim() === "") {
next.delete(key);
} else {
next.set(key, value);
}
setPage(0);
setSearchParams(next, { replace: true });
}
function resetFilters() {
setPage(0);
setSearchParams(new URLSearchParams(), { replace: true });
}
function handleDelete(subtitle: AdminDownloadedSubtitle) {
deleteMutation.mutate(subtitle.id);
}
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const canPrev = page > 0;
const canNext = (page + 1) * pageSize < total;
if (subtitlesQuery.isLoading) {
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<div className="space-y-3">
<Skeleton className="h-12 w-72 rounded-lg" />
<Skeleton className="h-5 w-full max-w-xl rounded-lg" />
</div>
<Skeleton className="h-20 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full rounded-lg" />
))}
</div>
);
}
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<div className="page-header gap-5">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Subtitles</h1>
<p className="page-subtitle text-sm sm:text-base">
Manage stored subtitle files across the library — user uploads and provider downloads.
</p>
</div>
</div>
<div className="surface-panel-subtle grid gap-4 rounded-2xl px-4 py-4 sm:grid-cols-2 xl:grid-cols-4">
<StatBlock label="Total stored" value={total} />
<StatBlock label="User uploads" value={uploads} />
<StatBlock label="Provider downloads" value={providerDownloads} />
<StatBlock label="Languages on page" value={languageCount} />
</div>
<AdminSubtitlesFilters
provider={provider}
language={language}
userId={userId}
search={search}
users={users}
onProviderChange={(value) => updateFilter("provider", value)}
onLanguageChange={(value) => updateFilter("language", value)}
onUserChange={(value) => updateFilter("user_id", value)}
onSearchChange={(value) => updateFilter("q", value)}
onReset={resetFilters}
/>
<AdminSubtitlesTable
subtitles={subtitles}
hasActiveFilters={hasActiveFilters}
onResetFilters={resetFilters}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
/>
{total > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm">
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
</p>
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPage(0);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size}>
{size} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
disabled={!canPrev}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span className="text-muted-foreground px-1 text-sm">
Page {page + 1} of {pageCount}
</span>
<Button
type="button"
variant="outline"
disabled={!canNext}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}
function StatBlock({ label, value }: { label: string; value: number }) {
return (
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium tracking-[0.18em] uppercase">
{label}
</div>
<div className="text-2xl font-semibold tracking-tight">{value.toLocaleString()}</div>
</div>
);
}
@@ -21,11 +21,14 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
searchSubtitles,
detectSubtitleLanguage,
useDownloadSubtitle,
useDownloadedSubtitles,
useUploadSubtitle,
} from "@/hooks/queries/subtitles";
import { cn } from "@/lib/utils";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
import { buildQualitySummary } from "./VersionFlyout";
interface SubtitleSearchDialogProps {
@@ -39,6 +42,7 @@ const providerInfo: Record<string, { abbr: string; className: string }> = {
opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" },
subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" },
subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" },
upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" },
};
function scoreTone(score: number): { text: string; ring: string; bg: string } {
@@ -77,6 +81,7 @@ export default function SubtitleSearchDialog({
title,
}: SubtitleSearchDialogProps) {
const downloadSubtitleMutation = useDownloadSubtitle();
const uploadSubtitleMutation = useUploadSubtitle();
const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined);
const searchAbortRef = useRef<AbortController | null>(null);
@@ -190,13 +195,41 @@ export default function SubtitleSearchDialog({
[downloadSubtitleMutation, downloadedQuery, version],
);
const handleUpload = useCallback(
async (input: {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}) => {
await uploadSubtitleMutation.mutateAsync({
media_file_id: input.mediaFileId,
file: input.file,
language: input.language,
language_override: input.languageOverride,
hearing_impaired: input.hearingImpaired,
});
},
[uploadSubtitleMutation],
);
const handleDetectLanguage = useCallback(
(file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage),
[],
);
const handleUploadSuccess = useCallback(async () => {
await downloadedQuery.refetch();
}, [downloadedQuery]);
const versionLabel = version ? buildQualitySummary(version) : "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl overflow-hidden sm:max-w-3xl">
<DialogHeader className="min-w-0">
<DialogTitle>Search Subtitles</DialogTitle>
<DialogTitle>Add Subtitles</DialogTitle>
<DialogDescription className="truncate">
{title}
{versionLabel ? ` \u00B7 ${versionLabel}` : ""}
@@ -205,28 +238,42 @@ export default function SubtitleSearchDialog({
<TooltipProvider delayDuration={250}>
<div className="min-w-0 space-y-4">
<div className="flex flex-col gap-2 sm:flex-row">
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
{version && (
<SubtitleUploadForm
mediaFileId={version.file_id}
upload={handleUpload}
detectLanguage={handleDetectLanguage}
onSuccess={handleUploadSuccess}
onError={setSearchError}
defaultLanguage={selectedLanguage}
/>
)}
<Button onClick={handleSearch} disabled={!version || searching}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
Search
</Button>
<div className="space-y-2">
<p className="text-sm font-medium">Search online</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSearch} disabled={!version || searching}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
Search
</Button>
</div>
</div>
{searchError && (
@@ -2,7 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import type { PlayerConfig } from "../context/PlayerConfigContext";
import { playerFetch } from "../player-fetch";
import type { SubtitleSearchResponse, SubtitleResult } from "@/api/types";
import type {
SubtitleLanguageDetection,
SubtitleSearchResponse,
SubtitleResult,
} from "@/api/types";
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
import { LANGUAGES } from "../utils/languageNames";
interface SubtitleSearchModalProps {
@@ -22,6 +27,7 @@ const providerInfo: Record<string, ProviderInfo> = {
opensubtitles: { abbr: "OS", color: "#eab308" },
subdl: { abbr: "SDL", color: "#3b82f6" },
subsource: { abbr: "SS", color: "#ef4444" },
upload: { abbr: "UP", color: "#a855f7" },
};
function scoreColor(score: number): string {
@@ -132,6 +138,56 @@ export function SubtitleSearchModal({
}
}, [playerConfig, mediaFileId, selectedLang]);
const handleUpload = useCallback(
async (input: {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}) => {
const form = new FormData();
form.set("media_file_id", String(input.mediaFileId));
if (input.language) {
form.set("language", input.language);
}
if (input.languageOverride) {
form.set("language_override", "true");
}
form.set("file", input.file);
if (input.hearingImpaired) {
form.set("hearing_impaired", "true");
}
await playerFetch(playerConfig, "/subtitles/upload", {
method: "POST",
body: form,
});
},
[playerConfig],
);
const handleDetectLanguage = useCallback(
async (file: File, fallbackLanguage?: string): Promise<SubtitleLanguageDetection> => {
const form = new FormData();
form.set("file", file);
if (fallbackLanguage) {
form.set("language", fallbackLanguage);
}
return playerFetch<SubtitleLanguageDetection>(playerConfig, "/subtitles/detect-language", {
method: "POST",
body: form,
});
},
[playerConfig],
);
const handleUploadSuccess = useCallback(() => {
onSubtitleDownloaded();
handleClose();
}, [onSubtitleDownloaded, handleClose]);
const handleDownload = useCallback(
async (result: SubtitleResult) => {
const key = `${result.provider}:${result.id}`;
@@ -171,7 +227,7 @@ export function SubtitleSearchModal({
onClick={handleClose}
role="dialog"
aria-modal="true"
aria-label="Subtitle Search"
aria-label="Add Subtitles"
onKeyDown={handleFocusTrap}
>
<div
@@ -181,7 +237,7 @@ export function SubtitleSearchModal({
>
{/* Header */}
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<h2 className="text-sm font-semibold">Search Subtitles</h2>
<h2 className="text-sm font-semibold">Add Subtitles</h2>
<button
type="button"
className="rounded text-white/60 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
@@ -192,6 +248,20 @@ export function SubtitleSearchModal({
</button>
</div>
<SubtitleUploadForm
mediaFileId={mediaFileId}
upload={handleUpload}
detectLanguage={handleDetectLanguage}
onSuccess={handleUploadSuccess}
onError={setError}
variant="player"
defaultLanguage={selectedLang}
/>
<div className="border-b border-white/10 px-4 py-2">
<p className="text-xs font-medium text-white/60">Search online</p>
</div>
{/* Search controls */}
<div className="flex gap-2 px-4 py-3">
<select
+3 -1
View File
@@ -33,9 +33,11 @@ export async function playerFetch<T>(
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
const token = config.getAccessToken();
if (token) {