From 28196232c9886444f0340151f47ab3a184071792 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 17:57:26 -0400 Subject: [PATCH] feat(subtitles): restore upload management --- go.mod | 1 + go.sum | 2 + .../handlers/admin_downloaded_subtitles.go | 326 ++++++++++++++ .../admin_downloaded_subtitles_test.go | 232 ++++++++++ internal/api/handlers/admin_subtitles.go | 3 + internal/api/handlers/media_file_auth.go | 70 +++ internal/api/handlers/media_file_auth_test.go | 102 +++++ internal/api/handlers/playback.go | 2 +- internal/api/handlers/subtitle_search.go | 182 +++++++- internal/api/handlers/subtitle_search_test.go | 330 ++++++++++++++ internal/api/middleware/demo_guard.go | 1 + internal/api/router.go | 24 +- .../playback_subtitle_auth_test.go | 4 + internal/subtitles/format.go | 50 +++ internal/subtitles/language_detect.go | 382 ++++++++++++++++ internal/subtitles/language_detect_test.go | 119 +++++ internal/subtitles/manager.go | 200 ++++++++- internal/subtitles/manager_test.go | 209 +++++++++ internal/subtitles/pgrepo.go | 21 + internal/subtitles/repo.go | 9 + ..._downloaded_subtitles_admin_index.down.sql | 2 + ...32_downloaded_subtitles_admin_index.up.sql | 5 + web/src/App.tsx | 2 + web/src/api/client.ts | 59 +++ web/src/api/types.ts | 55 +++ web/src/app.css | 22 + web/src/components/AdminSidebar.tsx | 6 + .../subtitles/AdminSubtitleEditSheet.tsx | 171 ++++++++ .../admin/subtitles/AdminSubtitlesFilters.tsx | 118 +++++ .../admin/subtitles/AdminSubtitlesTable.tsx | 272 ++++++++++++ .../admin/subtitles/subtitleAdminStyles.ts | 57 +++ .../subtitles/SubtitleUploadForm.tsx | 406 ++++++++++++++++++ web/src/hooks/queries/admin/subtitles.ts | 71 ++- web/src/hooks/queries/keys.ts | 9 + web/src/hooks/queries/subtitles.ts | 70 +++ web/src/lib/documentTitle.ts | 1 + web/src/pages/AdminSubtitles.tsx | 200 +++++++++ .../components/SubtitleSearchDialog.tsx | 91 +++- .../player/components/SubtitleSearchModal.tsx | 76 +++- web/src/player/player-fetch.ts | 4 +- 40 files changed, 3928 insertions(+), 38 deletions(-) create mode 100644 internal/api/handlers/admin_downloaded_subtitles.go create mode 100644 internal/api/handlers/admin_downloaded_subtitles_test.go create mode 100644 internal/api/handlers/media_file_auth.go create mode 100644 internal/api/handlers/media_file_auth_test.go create mode 100644 internal/api/handlers/subtitle_search_test.go create mode 100644 internal/subtitles/format.go create mode 100644 internal/subtitles/language_detect.go create mode 100644 internal/subtitles/language_detect_test.go create mode 100644 internal/subtitles/manager_test.go create mode 100644 migrations/132_downloaded_subtitles_admin_index.down.sql create mode 100644 migrations/132_downloaded_subtitles_admin_index.up.sql create mode 100644 web/src/components/admin/subtitles/AdminSubtitleEditSheet.tsx create mode 100644 web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx create mode 100644 web/src/components/admin/subtitles/AdminSubtitlesTable.tsx create mode 100644 web/src/components/admin/subtitles/subtitleAdminStyles.ts create mode 100644 web/src/components/subtitles/SubtitleUploadForm.tsx create mode 100644 web/src/pages/AdminSubtitles.tsx diff --git a/go.mod b/go.mod index 962d1bd0..f9c420ce 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 09c130bb..0d513135 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/handlers/admin_downloaded_subtitles.go b/internal/api/handlers/admin_downloaded_subtitles.go new file mode 100644 index 00000000..34aa3449 --- /dev/null +++ b/internal/api/handlers/admin_downloaded_subtitles.go @@ -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) +} diff --git a/internal/api/handlers/admin_downloaded_subtitles_test.go b/internal/api/handlers/admin_downloaded_subtitles_test.go new file mode 100644 index 00000000..1cb83daf --- /dev/null +++ b/internal/api/handlers/admin_downloaded_subtitles_test.go @@ -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") + } +} diff --git a/internal/api/handlers/admin_subtitles.go b/internal/api/handlers/admin_subtitles.go index 2798297f..bc86f1ac 100644 --- a/internal/api/handlers/admin_subtitles.go +++ b/internal/api/handlers/admin_subtitles.go @@ -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 } diff --git a/internal/api/handlers/media_file_auth.go b/internal/api/handlers/media_file_auth.go new file mode 100644 index 00000000..ef9b826a --- /dev/null +++ b/internal/api/handlers/media_file_auth.go @@ -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 +} diff --git a/internal/api/handlers/media_file_auth_test.go b/internal/api/handlers/media_file_auth_test.go new file mode 100644 index 00000000..f486152a --- /dev/null +++ b/internal/api/handlers/media_file_auth_test.go @@ -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") + } +} diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index ad4ec350..d922b5cf 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -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 diff --git a/internal/api/handlers/subtitle_search.go b/internal/api/handlers/subtitle_search.go index d85d8d40..f4054550 100644 --- a/internal/api/handlers/subtitle_search.go +++ b/internal/api/handlers/subtitle_search.go @@ -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 + } +} diff --git a/internal/api/handlers/subtitle_search_test.go b/internal/api/handlers/subtitle_search_test.go new file mode 100644 index 00000000..1490e9b6 --- /dev/null +++ b/internal/api/handlers/subtitle_search_test.go @@ -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 ©, 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 ©, 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 ©, 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 +} diff --git a/internal/api/middleware/demo_guard.go b/internal/api/middleware/demo_guard.go index 68a50bfe..9c2725f7 100644 --- a/internal/api/middleware/demo_guard.go +++ b/internal/api/middleware/demo_guard.go @@ -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/"}, } diff --git a/internal/api/router.go b/internal/api/router.go index 26a6169a..563617a2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 diff --git a/internal/jellycompat/playback_subtitle_auth_test.go b/internal/jellycompat/playback_subtitle_auth_test.go index e6996b57..f19f4581 100644 --- a/internal/jellycompat/playback_subtitle_auth_test.go +++ b/internal/jellycompat/playback_subtitle_auth_test.go @@ -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") } diff --git a/internal/subtitles/format.go b/internal/subtitles/format.go new file mode 100644 index 00000000..df881915 --- /dev/null +++ b/internal/subtitles/format.go @@ -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 +} diff --git a/internal/subtitles/language_detect.go b/internal/subtitles/language_detect.go new file mode 100644 index 00000000..c17f5516 --- /dev/null +++ b/internal/subtitles/language_detect.go @@ -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()) +} diff --git a/internal/subtitles/language_detect_test.go b/internal/subtitles/language_detect_test.go new file mode 100644 index 00000000..70b34c2d --- /dev/null +++ b/internal/subtitles/language_detect_test.go @@ -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) + } +} diff --git a/internal/subtitles/manager.go b/internal/subtitles/manager.go index 73db0cc1..a64e550e 100644 --- a/internal/subtitles/manager.go +++ b/internal/subtitles/manager.go @@ -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) diff --git a/internal/subtitles/manager_test.go b/internal/subtitles/manager_test.go new file mode 100644 index 00000000..a03c64c8 --- /dev/null +++ b/internal/subtitles/manager_test.go @@ -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 ©, nil + } + } + return nil, nil +} + +func (m *mockSubtitleRepo) DeleteDownloadedSubtitle(context.Context, int) (*DownloadedSubtitle, error) { + return nil, nil +} + +func (m *mockSubtitleRepo) GetDownloadedSubtitleByS3Key(_ context.Context, s3Key string) (*DownloadedSubtitle, error) { + m.mu.Lock() + defer m.mu.Unlock() + if sub, ok := m.byKey[s3Key]; ok { + copy := *sub + return ©, nil + } + return nil, nil +} + +func (m *mockSubtitleRepo) ListProviderConfigs(context.Context) ([]ProviderConfig, error) { + return nil, nil +} + +func (m *mockSubtitleRepo) GetProviderConfig(context.Context, string) (*ProviderConfig, error) { + return nil, nil +} + +func (m *mockSubtitleRepo) UpsertProviderConfig(context.Context, *ProviderConfig) error { + return nil +} + +type mockS3Client struct { + mu sync.Mutex + keys map[string][]byte + puts int + deletes int +} + +func newMockS3Client() *mockS3Client { + return &mockS3Client{keys: make(map[string][]byte)} +} + +func (m *mockS3Client) PutObject(_ context.Context, _, key string, data []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + m.puts++ + m.keys[key] = append([]byte(nil), data...) + return nil +} + +func (m *mockS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]byte(nil), m.keys[key]...), nil +} + +func (m *mockS3Client) DeleteObject(_ context.Context, _, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.deletes++ + delete(m.keys, key) + return nil +} + +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") + } +} diff --git a/internal/subtitles/pgrepo.go b/internal/subtitles/pgrepo.go index 8ec46abf..192659b3 100644 --- a/internal/subtitles/pgrepo.go +++ b/internal/subtitles/pgrepo.go @@ -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, diff --git a/internal/subtitles/repo.go b/internal/subtitles/repo.go index 966af025..41e164a2 100644 --- a/internal/subtitles/repo.go +++ b/internal/subtitles/repo.go @@ -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) diff --git a/migrations/132_downloaded_subtitles_admin_index.down.sql b/migrations/132_downloaded_subtitles_admin_index.down.sql new file mode 100644 index 00000000..a05e4426 --- /dev/null +++ b/migrations/132_downloaded_subtitles_admin_index.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_downloaded_subtitles_provider_created; +DROP INDEX IF EXISTS idx_downloaded_subtitles_created; diff --git a/migrations/132_downloaded_subtitles_admin_index.up.sql b/migrations/132_downloaded_subtitles_admin_index.up.sql new file mode 100644 index 00000000..d6a0e8dc --- /dev/null +++ b/migrations/132_downloaded_subtitles_admin_index.up.sql @@ -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); diff --git a/web/src/App.tsx b/web/src/App.tsx index 2d85ddc3..6f4e0874 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e4883636..55f62211 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -370,6 +370,65 @@ export async function api(path: string, options: RequestInit = {}): Promise { + const headers: Record = { + ...(options.headers as Record), + }; + 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 { + 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 { const params = new URLSearchParams({ q: query, limit: String(limit) }); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3764d4e4..a6f5fcc9 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/app.css b/web/src/app.css index 9776b429..31b0696a 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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. */ diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index 62559f3d..7fb67565 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -8,6 +8,7 @@ import { Users, MonitorSmartphone, History, + Captions, Download, SlidersHorizontal, Server, @@ -114,6 +115,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { icon: , href: "/admin/sections", }, + { + label: "Subtitles", + icon: , + href: "/admin/subtitles", + }, ], }, { diff --git a/web/src/components/admin/subtitles/AdminSubtitleEditSheet.tsx b/web/src/components/admin/subtitles/AdminSubtitleEditSheet.tsx new file mode 100644 index 00000000..2290e21d --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitleEditSheet.tsx @@ -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 ( + + + {subtitle ? ( + onOpenChange(false)} + /> + ) : ( + <> + + Edit subtitle + + + )} + + + ); +} + +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 ( + <> + + Edit subtitle + + Update stored metadata for this subtitle record. File content is not replaced. + + + +
+
+
{subtitle.media_title || "Unknown media"}
+
+ + {subtitle.language.toUpperCase()} · {getLanguageName(subtitle.language)} + + + {providerLabel(subtitle.provider)} + +
+
+ +
+ + + {languageChanged && ( +

+ Updates stored language label; file content unchanged. +

+ )} +
+ +
+ + setReleaseName(event.target.value)} + className="font-mono text-xs" + /> +
+ +
+
+ +

Marks this track as SDH/CC.

+
+ +
+
+ + + + + + + ); +} diff --git a/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx b/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx new file mode 100644 index 00000000..ea57242c --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx @@ -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 ( +
+
+
+ onSearchChange(event.target.value)} + placeholder="Search release name…" + className="font-mono text-xs sm:max-w-sm" + aria-label="Search subtitle release name" + /> +
+ +
+ {SUBTITLE_PROVIDER_OPTIONS.map((option) => { + const active = provider === option.value; + return ( + + ); + })} +
+ +
+ + + + + +
+
+
+ ); +} + +export { ALL as FILTER_ALL }; diff --git a/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx new file mode 100644 index 00000000..bdedf8ee --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx @@ -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(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [downloadingId, setDownloadingId] = useState(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 ( +
+
+ + + +
+

+ {hasActiveFilters ? "No subtitles match these filters" : "No stored subtitles yet"} +

+

+ {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."} +

+ {hasActiveFilters && ( + + )} +
+ ); + } + + return ( + <> +
+ + + + Media + File + Language + Provider + Release + Format + HI + Uploader + Added + Actions + + + + {subtitles.map((subtitle, index) => ( + + +
+ {subtitle.media_content_id ? ( + + {subtitle.media_title || subtitle.media_content_id} + + ) : ( +
+ {subtitle.media_title || "Unknown media"} +
+ )} + {subtitle.media_type === "episode" && ( + + Episode + + )} +
+
+ + {basenameFromPath(subtitle.file_path)} + + + + + {subtitle.language} + + + {getLanguageName(subtitle.language)} + + + + + + {providerLabel(subtitle.provider)} + + + + {subtitle.release_name || "—"} + + + + .{subtitle.format} + + + + {subtitle.hearing_impaired ? ( + + + ) : null} + + {subtitle.uploader_username || "—"} + + {formatRelative(subtitle.created_at)} + + +
+ + + +
+
+
+ ))} +
+
+
+ + { + if (!open) setEditTarget(null); + }} + /> + + { + 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); + } + }} + /> + + ); +} diff --git a/web/src/components/admin/subtitles/subtitleAdminStyles.ts b/web/src/components/admin/subtitles/subtitleAdminStyles.ts new file mode 100644 index 00000000..4f5b4411 --- /dev/null +++ b/web/src/components/admin/subtitles/subtitleAdminStyles.ts @@ -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; +} diff --git a/web/src/components/subtitles/SubtitleUploadForm.tsx b/web/src/components/subtitles/SubtitleUploadForm.tsx new file mode 100644 index 00000000..543ac340 --- /dev/null +++ b/web/src/components/subtitles/SubtitleUploadForm.tsx @@ -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; + detectLanguage?: (file: File, fallbackLanguage?: string) => Promise; + 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(null); + const dragDepthRef = useRef(0); + const detectRequestRef = useRef(0); + const [language, setLanguage] = useState(defaultLanguage); + const [hearingImpaired, setHearingImpaired] = useState(false); + const [selectedFile, setSelectedFile] = useState(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(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) => { + selectFile(event.target.files?.[0]); + }; + + const handleBrowseClick = () => { + fileInputRef.current?.click(); + }; + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current += 1; + setIsDragging(true); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragging(false); + } + }; + + const handleDrop = (event: React.DragEvent) => { + 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 ( +
+
+

+ Upload subtitle +

+

+ Drag and drop or browse for SRT, VTT, ASS, SSA, or SUB files up to 5 MB. Language is + detected automatically when possible. +

+
+ + + +
{ + 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", + )} + > +
+ +
+
+ {isPlayer ? ( + + ) : ( + + )} + {detectingLanguage ? ( +

+ Detecting language… +

+ ) : detectionSource && detectionSource !== "manual" ? ( +

+ Detected {getLanguageName(language)} from {detectionSourceLabel(detectionSource)} +

+ ) : null} +
+ + {isPlayer ? ( + + ) : ( +
+ + +
+ )} + + {isPlayer ? ( + + ) : ( + + )} +
+ + {selectedFile && ( +

+ Selected: {selectedFile.name} +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/web/src/hooks/queries/admin/subtitles.ts b/web/src/hooks/queries/admin/subtitles.ts index 278b2296..e95d60b8 100644 --- a/web/src/hooks/queries/admin/subtitles.ts +++ b/web/src/hooks/queries/admin/subtitles.ts @@ -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(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(`/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 { + 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(), diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index cf139be3..b0e8272c 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -360,6 +360,15 @@ export const adminKeys = { operationalLogs: (params: Record) => ["admin", "logs", "app", params] as const, auditLogs: (params: Record) => ["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, diff --git a/web/src/hooks/queries/subtitles.ts b/web/src/hooks/queries/subtitles.ts index c9267598..b56f6594 100644 --- a/web/src/hooks/queries/subtitles.ts +++ b/web/src/hooks/queries/subtitles.ts @@ -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 { + return api("/subtitles/upload", { + ...options, + method: "POST", + body: buildSubtitleUploadFormData(request), + }); +} + +export async function detectSubtitleLanguage( + file: File, + language?: string, + options?: RequestInit, +): Promise { + return api("/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"); + }, + }); +} diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index 93e68a36..a4f02200 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -31,6 +31,7 @@ const ADMIN_TITLES: Record = { recommendations: "Admin Recommendations", requests: "Admin Requests", sections: "Admin Sections", + subtitles: "Admin Subtitles", settings: "Admin Settings", tasks: "Admin Tasks", users: "Admin Users", diff --git a/web/src/pages/AdminSubtitles.tsx b/web/src/pages/AdminSubtitles.tsx new file mode 100644 index 00000000..8e4eac59 --- /dev/null +++ b/web/src/pages/AdminSubtitles.tsx @@ -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 ( +
+
+ + +
+ + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+
+
+

Subtitles

+

+ Manage stored subtitle files across the library — user uploads and provider downloads. +

+
+
+ +
+ + + + +
+ + updateFilter("provider", value)} + onLanguageChange={(value) => updateFilter("language", value)} + onUserChange={(value) => updateFilter("user_id", value)} + onSearchChange={(value) => updateFilter("q", value)} + onReset={resetFilters} + /> + + + + {total > 0 && ( +
+

+ Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total} +

+
+ + + + Page {page + 1} of {pageCount} + + +
+
+ )} +
+ ); +} + +function StatBlock({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} +
+
{value.toLocaleString()}
+
+ ); +} diff --git a/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx b/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx index 8aeba9e0..f29288f8 100644 --- a/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx +++ b/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx @@ -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 = { 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(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 ( - Search Subtitles + Add Subtitles {title} {versionLabel ? ` \u00B7 ${versionLabel}` : ""} @@ -205,28 +238,42 @@ export default function SubtitleSearchDialog({
-
- + {version && ( + + )} - +
+

Search online

+
+ + + +
{searchError && ( diff --git a/web/src/player/components/SubtitleSearchModal.tsx b/web/src/player/components/SubtitleSearchModal.tsx index a18a5f40..0a87cc5b 100644 --- a/web/src/player/components/SubtitleSearchModal.tsx +++ b/web/src/player/components/SubtitleSearchModal.tsx @@ -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 = { 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 => { + const form = new FormData(); + form.set("file", file); + if (fallbackLanguage) { + form.set("language", fallbackLanguage); + } + + return playerFetch(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} >
{/* Header */}
-

Search Subtitles

+

Add Subtitles

+ + +
+

Search online

+
+ {/* Search controls */}