Merge pull request #10 from Silo-Server/codex/restore-subtitle-uploads
feat(subtitles): restore upload management
This commit is contained in:
@@ -20,6 +20,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/bimg v1.1.9
|
||||
|
||||
@@ -2,6 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.4.0 h1:DJkRROQfr/kfwnF5dUdkhmbCso1KDLZc7uK7YfJnaO0=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.4.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
// AdminDownloadedSubtitle is the admin-facing view of a stored subtitle record.
|
||||
type AdminDownloadedSubtitle struct {
|
||||
ID int `json:"id"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
MediaContentID string `json:"media_content_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
Language string `json:"language"`
|
||||
Format string `json:"format"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
Score float64 `json:"score"`
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DownloadedBy *int `json:"downloaded_by,omitempty"`
|
||||
UploaderUsername string `json:"uploader_username"`
|
||||
MediaTitle string `json:"media_title"`
|
||||
MediaType string `json:"media_type"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
type adminDownloadedSubtitlesResponse struct {
|
||||
Subtitles []AdminDownloadedSubtitle `json:"subtitles"`
|
||||
Total int `json:"total"`
|
||||
Uploads int `json:"uploads"`
|
||||
Provider int `json:"provider_downloads"`
|
||||
}
|
||||
|
||||
type patchDownloadedSubtitleRequest struct {
|
||||
Language *string `json:"language"`
|
||||
ReleaseName *string `json:"release_name"`
|
||||
HearingImpaired *bool `json:"hearing_impaired"`
|
||||
}
|
||||
|
||||
// SetDownloadedSubtitleDeps wires optional dependencies for downloaded subtitle admin routes.
|
||||
func (h *AdminSubtitleHandler) SetDownloadedSubtitleDeps(pool *pgxpool.Pool, manager *subtitles.Manager) {
|
||||
h.pool = pool
|
||||
h.manager = manager
|
||||
}
|
||||
|
||||
// HandleListDownloadedSubtitles handles GET /api/v1/admin/subtitles.
|
||||
func (h *AdminSubtitleHandler) HandleListDownloadedSubtitles(w http.ResponseWriter, r *http.Request) {
|
||||
if h.pool == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
|
||||
return
|
||||
}
|
||||
|
||||
limit, offset := parsePagination(r)
|
||||
q := r.URL.Query()
|
||||
|
||||
var (
|
||||
args []any
|
||||
conditions []string
|
||||
argIndex = 1
|
||||
)
|
||||
|
||||
if provider := strings.TrimSpace(q.Get("provider")); provider != "" {
|
||||
conditions = append(conditions, "ds.provider = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, provider)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if language := strings.TrimSpace(q.Get("language")); language != "" {
|
||||
conditions = append(conditions, "ds.language = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, language)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if userIDStr := strings.TrimSpace(q.Get("user_id")); userIDStr != "" {
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid user_id")
|
||||
return
|
||||
}
|
||||
conditions = append(conditions, "ds.downloaded_by = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, userID)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if mediaFileIDStr := strings.TrimSpace(q.Get("media_file_id")); mediaFileIDStr != "" {
|
||||
mediaFileID, err := strconv.Atoi(mediaFileIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
|
||||
return
|
||||
}
|
||||
conditions = append(conditions, "ds.media_file_id = $"+strconv.Itoa(argIndex))
|
||||
args = append(args, mediaFileID)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if search := strings.TrimSpace(q.Get("q")); search != "" {
|
||||
conditions = append(conditions, "ds.release_name ILIKE $"+strconv.Itoa(argIndex))
|
||||
args = append(args, "%"+search+"%")
|
||||
argIndex++
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM downloaded_subtitles ds` + whereClause
|
||||
var total int
|
||||
if err := h.pool.QueryRow(r.Context(), countQuery, args...).Scan(&total); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitles")
|
||||
return
|
||||
}
|
||||
|
||||
statsQuery := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE ds.provider = 'upload'),
|
||||
COUNT(*) FILTER (WHERE ds.provider <> 'upload')
|
||||
FROM downloaded_subtitles ds` + whereClause
|
||||
var uploads, providerDownloads int
|
||||
if err := h.pool.QueryRow(r.Context(), statsQuery, args...).Scan(&uploads, &providerDownloads); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count subtitle stats")
|
||||
return
|
||||
}
|
||||
|
||||
listQuery := `
|
||||
SELECT
|
||||
ds.id,
|
||||
ds.media_file_id,
|
||||
COALESCE(mf.content_id, ''),
|
||||
ds.provider,
|
||||
ds.language,
|
||||
ds.format,
|
||||
ds.release_name,
|
||||
ds.score,
|
||||
ds.hearing_impaired,
|
||||
ds.created_at,
|
||||
ds.downloaded_by,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(ep.title, mi.title, ''),
|
||||
COALESCE(CASE WHEN ep.content_id IS NOT NULL THEN 'episode' ELSE mi.type END, ''),
|
||||
COALESCE(mf.file_path, '')
|
||||
FROM downloaded_subtitles ds
|
||||
LEFT JOIN users u ON u.id = ds.downloaded_by
|
||||
LEFT JOIN media_files mf ON mf.id = ds.media_file_id
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
LEFT JOIN episodes ep ON ep.content_id = mf.content_id` + whereClause + `
|
||||
ORDER BY ds.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
|
||||
|
||||
listArgs := append(append([]any{}, args...), limit, offset)
|
||||
rows, err := h.pool.Query(r.Context(), listQuery, listArgs...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list subtitles")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
subtitlesList := make([]AdminDownloadedSubtitle, 0)
|
||||
for rows.Next() {
|
||||
var row AdminDownloadedSubtitle
|
||||
if err := rows.Scan(
|
||||
&row.ID,
|
||||
&row.MediaFileID,
|
||||
&row.MediaContentID,
|
||||
&row.Provider,
|
||||
&row.Language,
|
||||
&row.Format,
|
||||
&row.ReleaseName,
|
||||
&row.Score,
|
||||
&row.HearingImpaired,
|
||||
&row.CreatedAt,
|
||||
&row.DownloadedBy,
|
||||
&row.UploaderUsername,
|
||||
&row.MediaTitle,
|
||||
&row.MediaType,
|
||||
&row.FilePath,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan subtitle row")
|
||||
return
|
||||
}
|
||||
subtitlesList = append(subtitlesList, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate subtitles")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminDownloadedSubtitlesResponse{
|
||||
Subtitles: subtitlesList,
|
||||
Total: total,
|
||||
Uploads: uploads,
|
||||
Provider: providerDownloads,
|
||||
})
|
||||
}
|
||||
|
||||
// HandlePatchDownloadedSubtitle handles PATCH /api/v1/admin/subtitles/{id}.
|
||||
func (h *AdminSubtitleHandler) HandlePatchDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
if h.manager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req patchDownloadedSubtitleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Language == nil && req.ReleaseName == nil && req.HearingImpaired == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "No fields to update")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.manager.UpdateDownloadedSubtitle(r.Context(), id, subtitles.SubtitleMetadataPatch{
|
||||
Language: req.Language,
|
||||
ReleaseName: req.ReleaseName,
|
||||
HearingImpaired: req.HearingImpaired,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, subtitles.ErrSubtitleNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
|
||||
case errors.Is(err, subtitles.ErrSubtitleLanguageConflict):
|
||||
writeError(w, http.StatusConflict, "conflict", "Subtitle with this language already exists for this file")
|
||||
default:
|
||||
if strings.Contains(err.Error(), "invalid subtitle language") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "update_error", "Failed to update subtitle")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"subtitle": updated})
|
||||
}
|
||||
|
||||
// HandleDownloadDownloadedSubtitle handles GET /api/v1/admin/subtitles/{id}/download.
|
||||
func (h *AdminSubtitleHandler) HandleDownloadDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
if h.manager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
|
||||
return
|
||||
}
|
||||
|
||||
sub, data, err := h.manager.GetSubtitleContent(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, subtitles.ErrSubtitleNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle")
|
||||
return
|
||||
}
|
||||
|
||||
filename := subtitleDownloadFilename(sub)
|
||||
w.Header().Set("Content-Type", subtitles.SubtitleContentType(sub.Format))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleDeleteDownloadedSubtitle handles DELETE /api/v1/admin/subtitles/{id}.
|
||||
func (h *AdminSubtitleHandler) HandleDeleteDownloadedSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
if h.manager == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Subtitle manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid subtitle ID")
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := h.repo.GetDownloadedSubtitle(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "lookup_error", "Failed to look up subtitle")
|
||||
return
|
||||
}
|
||||
if sub == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteSubtitle(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "delete_error", "Failed to delete subtitle")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func subtitleDownloadFilename(sub *subtitles.DownloadedSubtitle) string {
|
||||
base := strings.TrimSpace(sub.ReleaseName)
|
||||
if base == "" {
|
||||
base = fmt.Sprintf("subtitle-%d", sub.ID)
|
||||
}
|
||||
base = path.Base(base)
|
||||
base = strings.TrimSuffix(base, path.Ext(base))
|
||||
if base == "" || base == "." {
|
||||
base = fmt.Sprintf("subtitle-%d", sub.ID)
|
||||
}
|
||||
return fmt.Sprintf("%s.%s", base, sub.Format)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withSubtitleRouteParam(req *http.Request, key, value string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func TestHandlePatchDownloadedSubtitleUpdatesMetadata(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[7] = &subtitles.DownloadedSubtitle{
|
||||
ID: 7,
|
||||
MediaFileID: 42,
|
||||
Provider: subtitles.ProviderUpload,
|
||||
Language: "en",
|
||||
Format: subtitles.FormatSRT,
|
||||
ReleaseName: "movie.en.srt",
|
||||
S3Key: "subtitles/42/en_upload_abcd1234.srt",
|
||||
}
|
||||
repo.byKey["subtitles/42/en_upload_abcd1234.srt"] = repo.subtitles[7]
|
||||
|
||||
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
|
||||
"subtitles/42/en_upload_abcd1234.srt": []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"),
|
||||
}}
|
||||
manager := subtitles.NewManager(repo, s3, "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"language": "es",
|
||||
"release_name": "movie.es.srt",
|
||||
"hearing_impaired": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
|
||||
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/7", body)
|
||||
req = withSubtitleRouteParam(req, "id", "7")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandlePatchDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
updated := repo.subtitles[7]
|
||||
if updated.Language != "es" {
|
||||
t.Fatalf("language = %q, want es", updated.Language)
|
||||
}
|
||||
if updated.ReleaseName != "movie.es.srt" {
|
||||
t.Fatalf("release_name = %q, want movie.es.srt", updated.ReleaseName)
|
||||
}
|
||||
if !updated.HearingImpaired {
|
||||
t.Fatal("expected hearing_impaired=true")
|
||||
}
|
||||
if updated.S3Key == "subtitles/42/en_upload_abcd1234.srt" {
|
||||
t.Fatalf("expected migrated s3 key, got %q", updated.S3Key)
|
||||
}
|
||||
if len(s3.putKeys) != 1 {
|
||||
t.Fatalf("putKeys = %d, want 1", len(s3.putKeys))
|
||||
}
|
||||
if len(s3.deletedKeys) != 1 {
|
||||
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchDownloadedSubtitleNotFound(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
body := []byte(`{"language":"fr"}`)
|
||||
req := newAdminSubtitleRequest(http.MethodPatch, "/admin/subtitles/404", body)
|
||||
req = withSubtitleRouteParam(req, "id", "404")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandlePatchDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDownloadDownloadedSubtitle(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
content := []byte("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n")
|
||||
repo.subtitles[3] = &subtitles.DownloadedSubtitle{
|
||||
ID: 3,
|
||||
MediaFileID: 10,
|
||||
Provider: subtitles.ProviderUpload,
|
||||
Language: "en",
|
||||
Format: subtitles.FormatVTT,
|
||||
ReleaseName: "sample.vtt",
|
||||
S3Key: "subtitles/10/en_upload_deadbeef.vtt",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
|
||||
"subtitles/10/en_upload_deadbeef.vtt": content,
|
||||
}}
|
||||
manager := subtitles.NewManager(repo, s3, "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
req := newAdminSubtitleRequest(http.MethodGet, "/admin/subtitles/3/download", nil)
|
||||
req = withSubtitleRouteParam(req, "id", "3")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDownloadDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "text/vtt; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if !bytes.Contains([]byte(rr.Header().Get("Content-Disposition")), []byte("sample.vtt")) {
|
||||
t.Fatalf("content-disposition = %q", rr.Header().Get("Content-Disposition"))
|
||||
}
|
||||
if !bytes.Equal(rr.Body.Bytes(), content) {
|
||||
t.Fatalf("body mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteDownloadedSubtitle(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[5] = &subtitles.DownloadedSubtitle{
|
||||
ID: 5,
|
||||
MediaFileID: 11,
|
||||
Provider: "opensubtitles",
|
||||
Language: "en",
|
||||
Format: subtitles.FormatSRT,
|
||||
S3Key: "subtitles/11/en_opensubtitles_abcd1234.srt",
|
||||
}
|
||||
repo.byKey[repo.subtitles[5].S3Key] = repo.subtitles[5]
|
||||
s3 := &trackingHandlerS3Client{objects: map[string][]byte{
|
||||
repo.subtitles[5].S3Key: []byte("subtitle"),
|
||||
}}
|
||||
manager := subtitles.NewManager(repo, s3, "test-bucket")
|
||||
handler := NewAdminSubtitleHandler(repo)
|
||||
handler.SetDownloadedSubtitleDeps(nil, manager)
|
||||
|
||||
req := newAdminSubtitleRequest(http.MethodDelete, "/admin/subtitles/5", nil)
|
||||
req = withSubtitleRouteParam(req, "id", "5")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDeleteDownloadedSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rr.Code)
|
||||
}
|
||||
if _, ok := repo.subtitles[5]; ok {
|
||||
t.Fatal("expected subtitle record deleted")
|
||||
}
|
||||
if len(s3.deletedKeys) != 1 {
|
||||
t.Fatalf("deletedKeys = %d, want 1", len(s3.deletedKeys))
|
||||
}
|
||||
}
|
||||
|
||||
type trackingHandlerS3Client struct {
|
||||
objects map[string][]byte
|
||||
putKeys []string
|
||||
deletedKeys []string
|
||||
}
|
||||
|
||||
func (c *trackingHandlerS3Client) PutObject(_ context.Context, _, key string, data []byte) error {
|
||||
if c.objects == nil {
|
||||
c.objects = make(map[string][]byte)
|
||||
}
|
||||
c.objects[key] = append([]byte(nil), data...)
|
||||
c.putKeys = append(c.putKeys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *trackingHandlerS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
|
||||
if data, ok := c.objects[key]; ok {
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func (c *trackingHandlerS3Client) DeleteObject(_ context.Context, _, key string) error {
|
||||
delete(c.objects, key)
|
||||
c.deletedKeys = append(c.deletedKeys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubtitleDownloadFilename(t *testing.T) {
|
||||
sub := &subtitles.DownloadedSubtitle{
|
||||
ID: 9,
|
||||
ReleaseName: "../unsafe/path/movie.en.srt",
|
||||
Format: subtitles.FormatSRT,
|
||||
}
|
||||
got := subtitleDownloadFilename(sub)
|
||||
if got != "movie.en.srt" {
|
||||
t.Fatalf("filename = %q, want movie.en.srt", got)
|
||||
}
|
||||
if strconv.Itoa(sub.ID) == "" {
|
||||
t.Fatal("unexpected")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
// MediaFileAuthorizer validates that the authenticated user can access a media file.
|
||||
type MediaFileAuthorizer struct {
|
||||
FileResolver FilePathResolver
|
||||
ItemAccess PlaybackItemAccessChecker
|
||||
EpisodeLookup PlaybackEpisodeLookup
|
||||
}
|
||||
|
||||
// Authorize returns the media file when the caller may access it, or catalog.ErrItemNotFound.
|
||||
func (a *MediaFileAuthorizer) Authorize(r *http.Request, fileID int) (*models.MediaFile, error) {
|
||||
if a == nil || a.FileResolver == nil || a.ItemAccess == nil {
|
||||
return nil, fmt.Errorf("media file authorization dependencies not configured")
|
||||
}
|
||||
|
||||
file, err := a.FileResolver.GetByID(r.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, mapMediaFileLookupError(err)
|
||||
}
|
||||
if file == nil || file.MissingSince != nil {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
filter := requestAccessFilter(r)
|
||||
switch {
|
||||
case file.EpisodeID != "":
|
||||
if a.EpisodeLookup == nil {
|
||||
return nil, fmt.Errorf("episode lookup not configured")
|
||||
}
|
||||
episode, err := a.EpisodeLookup.GetByID(r.Context(), file.EpisodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if episode == nil {
|
||||
return nil, catalog.ErrEpisodeNotFound
|
||||
}
|
||||
if err := a.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case file.ContentID != "":
|
||||
if err := a.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
if !catalog.FileAllowedByAccess(file, filter) {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func mapMediaFileLookupError(err error) error {
|
||||
if errors.Is(err, scanner.ErrFileNotFound) {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
func TestMediaFileAuthorizerMapsMissingFileToNotFound(t *testing.T) {
|
||||
authorizer := &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
}))
|
||||
|
||||
_, err := authorizer.Authorize(req, 99)
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("Authorize() error = %v, want ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadMissingMediaFileReturns404(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListMissingMediaFileReturns404(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodGet, "/subtitles/99", nil)
|
||||
req = withProfileRouteParam(req, "media_file_id", "99")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleList(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaFileAuthorizerAllowsAccessibleFile(t *testing.T) {
|
||||
authorizer := &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
}))
|
||||
|
||||
file, err := authorizer.Authorize(req, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize() error = %v", err)
|
||||
}
|
||||
if file == nil || file.ID != 42 {
|
||||
t.Fatalf("file = %#v, want id 42", file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMediaFileLookupError(t *testing.T) {
|
||||
if !errors.Is(mapMediaFileLookupError(scanner.ErrFileNotFound), catalog.ErrItemNotFound) {
|
||||
t.Fatal("expected scanner.ErrFileNotFound to map to catalog.ErrItemNotFound")
|
||||
}
|
||||
if mapMediaFileLookupError(errors.New("db down")) == nil {
|
||||
t.Fatal("expected unrelated error to pass through")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,16 +3,40 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
subtitleUploadMaxSize = subtitles.MaxUploadSize
|
||||
// Allow multipart framing and small form fields above the file size cap.
|
||||
subtitleUploadMaxBodySize = subtitleUploadMaxSize + (256 << 10)
|
||||
)
|
||||
|
||||
func parseSubtitleMultipartForm(w http.ResponseWriter, r *http.Request) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, subtitleUploadMaxBodySize)
|
||||
if err := r.ParseMultipartForm(subtitleUploadMaxSize); err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SubtitleMediaResolver looks up media metadata for subtitle search.
|
||||
type SubtitleMediaResolver interface {
|
||||
GetMediaFileWithMetadata(ctx context.Context, fileID int) (*MediaFileMetadata, error)
|
||||
@@ -36,9 +60,10 @@ type MediaFileMetadata struct {
|
||||
|
||||
// SubtitleSearchHandler handles user-facing subtitle search operations.
|
||||
type SubtitleSearchHandler struct {
|
||||
manager *subtitles.Manager
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
manager *subtitles.Manager
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
}
|
||||
|
||||
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
|
||||
@@ -70,6 +95,23 @@ type downloadSubtitleRequest struct {
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
}
|
||||
|
||||
func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *http.Request, fileID int) bool {
|
||||
if h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Media file authorization is not configured")
|
||||
return false
|
||||
}
|
||||
if _, err := h.FileAuthorizer.Authorize(r, fileID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleSearch handles POST /api/v1/subtitles/search
|
||||
func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
var req searchSubtitlesRequest
|
||||
@@ -78,6 +120,10 @@ func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := h.mediaResolver.GetMediaFileWithMetadata(r.Context(), req.MediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "metadata_error", "Failed to look up media metadata")
|
||||
@@ -124,6 +170,10 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, req.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
sub, err := h.manager.Download(r.Context(), subtitles.DownloadRequest{
|
||||
@@ -145,6 +195,115 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
|
||||
}
|
||||
|
||||
// HandleUpload handles POST /api/v1/subtitles/upload
|
||||
func (h *SubtitleSearchHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !parseSubtitleMultipartForm(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
mediaFileID, err := strconv.Atoi(strings.TrimSpace(r.FormValue("media_file_id")))
|
||||
if err != nil || mediaFileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid media_file_id")
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, mediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > subtitleUploadMaxSize {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
return
|
||||
}
|
||||
|
||||
releaseName := strings.TrimSpace(r.FormValue("release_name"))
|
||||
hearingImpaired := parseBoolFormValue(r.FormValue("hearing_impaired"))
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
userLanguage := strings.TrimSpace(r.FormValue("language"))
|
||||
preferUserLanguage := parseBoolFormValue(r.FormValue("language_override"))
|
||||
|
||||
sub, err := h.manager.Upload(r.Context(), subtitles.UploadRequest{
|
||||
MediaFileID: mediaFileID,
|
||||
UserID: &userID,
|
||||
Language: userLanguage,
|
||||
PreferUserLanguage: preferUserLanguage,
|
||||
Filename: header.Filename,
|
||||
ReleaseName: releaseName,
|
||||
HearingImpaired: hearingImpaired,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "unsupported subtitle format"),
|
||||
strings.Contains(err.Error(), "missing file extension"),
|
||||
strings.Contains(err.Error(), "empty subtitle file"),
|
||||
strings.Contains(err.Error(), "could not detect subtitle language"),
|
||||
strings.Contains(err.Error(), "invalid subtitle language"):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
case strings.Contains(err.Error(), "exceeds maximum size"):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
default:
|
||||
slog.Error("subtitle upload failed", "media_file_id", mediaFileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload_error", "Failed to upload subtitle")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"subtitle": sub})
|
||||
}
|
||||
|
||||
// HandleDetectLanguage handles POST /api/v1/subtitles/detect-language
|
||||
func (h *SubtitleSearchHandler) HandleDetectLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
if !parseSubtitleMultipartForm(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Missing subtitle file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, subtitleUploadMaxSize+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > subtitleUploadMaxSize {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
return
|
||||
}
|
||||
|
||||
format, err := subtitles.FormatFromFilename(header.Filename)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userLanguage := strings.TrimSpace(r.FormValue("language"))
|
||||
detected, err := subtitles.ResolveUploadLanguage(header.Filename, format, data, userLanguage, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, detected)
|
||||
}
|
||||
|
||||
// HandleList handles GET /api/v1/subtitles/{media_file_id}
|
||||
func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
mediaFileID, err := strconv.Atoi(chi.URLParam(r, "media_file_id"))
|
||||
@@ -153,6 +312,10 @@ func (h *SubtitleSearchHandler) HandleList(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, mediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
subs, err := h.repo.ListDownloadedSubtitles(r.Context(), mediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list subtitles")
|
||||
@@ -180,6 +343,10 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if !h.authorizeMediaFile(w, r, sub.MediaFileID) {
|
||||
return
|
||||
}
|
||||
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
isAdmin := claims != nil && claims.Role == "admin"
|
||||
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
|
||||
@@ -204,3 +371,12 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBoolFormValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
type stubSubtitleMediaResolver struct {
|
||||
meta *MediaFileMetadata
|
||||
}
|
||||
|
||||
func (s stubSubtitleMediaResolver) GetMediaFileWithMetadata(context.Context, int) (*MediaFileMetadata, error) {
|
||||
return s.meta, nil
|
||||
}
|
||||
|
||||
type stubMediaFileResolver struct {
|
||||
file *models.MediaFile
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubMediaFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) {
|
||||
return s.file, s.err
|
||||
}
|
||||
|
||||
type stubItemAccessChecker struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubItemAccessChecker) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
type stubEpisodeLookup struct {
|
||||
episode *models.Episode
|
||||
}
|
||||
|
||||
func (s stubEpisodeLookup) GetByID(context.Context, string) (*models.Episode, error) {
|
||||
return s.episode, nil
|
||||
}
|
||||
|
||||
func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func newSubtitleUploadRequest(t *testing.T, mediaFileID int, language, filename string, content []byte) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("media_file_id", strconv.Itoa(mediaFileID)); err != nil {
|
||||
t.Fatalf("write media_file_id: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("language", language); err != nil {
|
||||
t.Fatalf("write language: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write file content: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/upload", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHandleUploadSuccess(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 42, "en", "custom.srt", []byte("1\n00:00:01,000 --> 00:00:02,000\nHi\n"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Subtitle subtitles.DownloadedSubtitle `json:"subtitle"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Subtitle.Provider != subtitles.ProviderUpload {
|
||||
t.Fatalf("provider = %q, want upload", resp.Subtitle.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadUnauthorizedMediaFile(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 99, "en", "custom.srt", []byte("hello"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadRejectsBadExtension(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(t, 42, "en", "notes.txt", []byte("hello"))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadRejectsOversizedBody(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{ID: 42, ContentID: "movie-1"},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleUploadRequest(
|
||||
t,
|
||||
42,
|
||||
"en",
|
||||
"huge.srt",
|
||||
make([]byte, subtitleUploadMaxBodySize+1),
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpload(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDetectLanguageRejectsOversizedBody(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "huge.srt")
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(make([]byte, subtitleUploadMaxBodySize+1)); err != nil {
|
||||
t.Fatalf("write file content: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodPost, "/subtitles/detect-language", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDetectLanguage(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteRequiresAccessToMediaFile(t *testing.T) {
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[1] = &subtitles.DownloadedSubtitle{
|
||||
ID: 1,
|
||||
MediaFileID: 42,
|
||||
Provider: subtitles.ProviderUpload,
|
||||
}
|
||||
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
|
||||
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
|
||||
handler.FileAuthorizer = &MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: scanner.ErrFileNotFound},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
}
|
||||
|
||||
req := newSubtitleAuthRequest(http.MethodDelete, "/subtitles/1", nil)
|
||||
req = withProfileRouteParam(req, "id", "1")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDelete(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type handlerMockSubtitleRepo struct {
|
||||
subtitles map[int]*subtitles.DownloadedSubtitle
|
||||
nextID int
|
||||
byKey map[string]*subtitles.DownloadedSubtitle
|
||||
}
|
||||
|
||||
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
|
||||
return &handlerMockSubtitleRepo{
|
||||
subtitles: make(map[int]*subtitles.DownloadedSubtitle),
|
||||
byKey: make(map[string]*subtitles.DownloadedSubtitle),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, sub *subtitles.DownloadedSubtitle) error {
|
||||
m.nextID++
|
||||
sub.ID = m.nextID
|
||||
m.subtitles[sub.ID] = sub
|
||||
m.byKey[sub.S3Key] = sub
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
if sub, ok := m.subtitles[id]; ok {
|
||||
copy := *sub
|
||||
return ©, 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
|
||||
}
|
||||
@@ -39,6 +39,7 @@ var demoBlockedRoutes = []blockedRoute{
|
||||
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/downloads"},
|
||||
{methods: []string{"POST"}, prefix: "/api/v1/history-imports"},
|
||||
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/download"},
|
||||
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/upload"},
|
||||
{methods: []string{"DELETE"}, prefix: "/api/v1/subtitles/"},
|
||||
}
|
||||
|
||||
|
||||
+23
-1
@@ -714,6 +714,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
|
||||
// Admin subtitle config handler only needs the DB repo — no S3 required.
|
||||
var adminSubtitleHandler *handlers.AdminSubtitleHandler
|
||||
var subtitleManager *subtitles.Manager
|
||||
if subtitleRepo != nil {
|
||||
adminSubtitleHandler = handlers.NewAdminSubtitleHandler(subtitleRepo)
|
||||
}
|
||||
@@ -721,7 +722,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Build subtitle search handler if we have DB and S3.
|
||||
var subtitleSearchHandler *handlers.SubtitleSearchHandler
|
||||
if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil {
|
||||
subtitleManager := subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
|
||||
subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
|
||||
|
||||
// Load provider configs from DB and register enabled providers.
|
||||
providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext)
|
||||
@@ -755,6 +756,10 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
|
||||
}
|
||||
|
||||
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
|
||||
adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager)
|
||||
}
|
||||
|
||||
// Build section handler if DB is available.
|
||||
var sectionHandler *handlers.SectionHandler
|
||||
var sectionSettingsHandler *handlers.SectionSettingsHandler
|
||||
@@ -1512,9 +1517,18 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
|
||||
// Subtitle search routes.
|
||||
if subtitleSearchHandler != nil {
|
||||
if deps.FileRepo != nil && itemRepo != nil {
|
||||
subtitleSearchHandler.FileAuthorizer = &handlers.MediaFileAuthorizer{
|
||||
FileResolver: deps.FileRepo,
|
||||
ItemAccess: itemRepo,
|
||||
EpisodeLookup: episodeRepo,
|
||||
}
|
||||
}
|
||||
r.Route("/subtitles", func(r chi.Router) {
|
||||
r.Post("/search", subtitleSearchHandler.HandleSearch)
|
||||
r.Post("/download", subtitleSearchHandler.HandleDownload)
|
||||
r.Post("/upload", subtitleSearchHandler.HandleUpload)
|
||||
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
|
||||
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
|
||||
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
|
||||
})
|
||||
@@ -1888,6 +1902,14 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Post("/test", adminSubtitleHandler.HandleTestProvider)
|
||||
})
|
||||
})
|
||||
r.Route("/subtitles", func(r chi.Router) {
|
||||
r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle)
|
||||
r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle)
|
||||
r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Rate limit admin routes
|
||||
|
||||
@@ -33,6 +33,10 @@ func (r fakeSubtitleRepository) ListDownloadedSubtitles(_ context.Context, media
|
||||
return r.downloaded[mediaFileID], nil
|
||||
}
|
||||
|
||||
func (r fakeSubtitleRepository) UpdateDownloadedSubtitle(context.Context, int, subtitles.SubtitleMetadataUpdate) (*subtitles.DownloadedSubtitle, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (r fakeSubtitleRepository) DeleteDownloadedSubtitle(context.Context, int) (*subtitles.DownloadedSubtitle, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,11 +3,20 @@ package subtitles
|
||||
|
||||
import "context"
|
||||
|
||||
// SubtitleMetadataUpdate contains mutable fields for a downloaded subtitle record.
|
||||
type SubtitleMetadataUpdate struct {
|
||||
Language string
|
||||
ReleaseName string
|
||||
HearingImpaired bool
|
||||
S3Key string
|
||||
}
|
||||
|
||||
// Repository defines database operations for subtitle management.
|
||||
type Repository interface {
|
||||
InsertDownloadedSubtitle(ctx context.Context, sub *DownloadedSubtitle) error
|
||||
GetDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error)
|
||||
ListDownloadedSubtitles(ctx context.Context, mediaFileID int) ([]DownloadedSubtitle, error)
|
||||
UpdateDownloadedSubtitle(ctx context.Context, id int, update SubtitleMetadataUpdate) (*DownloadedSubtitle, error)
|
||||
DeleteDownloadedSubtitle(ctx context.Context, id int) (*DownloadedSubtitle, error)
|
||||
GetDownloadedSubtitleByS3Key(ctx context.Context, s3Key string) (*DownloadedSubtitle, error)
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_downloaded_subtitles_provider_created;
|
||||
DROP INDEX IF EXISTS idx_downloaded_subtitles_created;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_subtitles_created
|
||||
ON downloaded_subtitles (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_subtitles_provider_created
|
||||
ON downloaded_subtitles (provider, created_at DESC);
|
||||
@@ -50,6 +50,7 @@ import AdminCollectionEditor from "@/pages/AdminCollectionEditor";
|
||||
import AdminPlaybackHistory from "@/pages/AdminPlaybackHistory";
|
||||
import AdminMaintenance from "@/pages/AdminMaintenance";
|
||||
import AdminApiKeys from "@/pages/AdminApiKeys";
|
||||
import AdminSubtitles from "@/pages/AdminSubtitles";
|
||||
import AdminUserDetail from "@/pages/AdminUserDetail";
|
||||
import AdminTasks from "@/pages/AdminTasks";
|
||||
import AdminTaskDetail from "@/pages/AdminTaskDetail";
|
||||
@@ -383,6 +384,7 @@ function AppRoutes() {
|
||||
<Route path="settings" element={<AdminSettingsLayout />} />
|
||||
<Route path="recommendations" element={<AdminRecommendations />} />
|
||||
<Route path="api-keys" element={<AdminApiKeys />} />
|
||||
<Route path="subtitles" element={<AdminSubtitles />} />
|
||||
<Route path="tasks" element={<AdminTasks />} />
|
||||
<Route path="tasks/:key" element={<AdminTaskDetail />} />
|
||||
<Route path="stats" element={<Navigate to="/admin" replace />} />
|
||||
|
||||
@@ -370,6 +370,65 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
function buildApiHeaders(options: RequestInit = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
|
||||
}
|
||||
if (accessToken) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
const profileId = getProfileId();
|
||||
if (profileId) {
|
||||
headers["X-Profile-Id"] = profileId;
|
||||
}
|
||||
const profToken = getProfileToken();
|
||||
if (profToken) {
|
||||
headers["X-Profile-Token"] = profToken;
|
||||
}
|
||||
Object.assign(headers, getDeviceHeaders());
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Downloads a binary API response and triggers a browser file save. */
|
||||
export async function apiDownload(
|
||||
path: string,
|
||||
filename: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<void> {
|
||||
let headers = buildApiHeaders(options);
|
||||
let res = await fetch(`/api/v1${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401 && getRefreshToken()) {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = attemptRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
const refreshed = await refreshPromise;
|
||||
if (refreshed) {
|
||||
headers = buildApiHeaders(options);
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
res = await fetch(`/api/v1${path}`, { ...options, headers });
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const apiErr = await parseApiError(res);
|
||||
throw new ApiClientError(res.status, apiErr.error, apiErr.message, apiErr);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// People API
|
||||
export async function searchPeople(query: string, limit = 20): Promise<import("./types").Person[]> {
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
MonitorSmartphone,
|
||||
History,
|
||||
Captions,
|
||||
Download,
|
||||
SlidersHorizontal,
|
||||
Server,
|
||||
@@ -114,6 +115,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
|
||||
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
|
||||
href: "/admin/sections",
|
||||
},
|
||||
{
|
||||
label: "Subtitles",
|
||||
icon: <Captions className="h-[18px] w-[18px]" />,
|
||||
href: "/admin/subtitles",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import type { AdminDownloadedSubtitle } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAdminUpdateDownloadedSubtitle } from "@/hooks/queries/admin/subtitles";
|
||||
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { languageChipClass, providerBadgeClass, providerLabel } from "./subtitleAdminStyles";
|
||||
|
||||
interface AdminSubtitleEditSheetProps {
|
||||
subtitle: AdminDownloadedSubtitle | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AdminSubtitleEditSheet({
|
||||
subtitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AdminSubtitleEditSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col sm:max-w-md">
|
||||
{subtitle ? (
|
||||
<AdminSubtitleEditForm
|
||||
key={subtitle.id}
|
||||
subtitle={subtitle}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit subtitle</SheetTitle>
|
||||
</SheetHeader>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSubtitleEditForm({
|
||||
subtitle,
|
||||
onClose,
|
||||
}: {
|
||||
subtitle: AdminDownloadedSubtitle;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const updateMutation = useAdminUpdateDownloadedSubtitle();
|
||||
const [language, setLanguage] = useState(subtitle.language);
|
||||
const [releaseName, setReleaseName] = useState(subtitle.release_name);
|
||||
const [hearingImpaired, setHearingImpaired] = useState(subtitle.hearing_impaired);
|
||||
|
||||
const languageChanged = language !== subtitle.language;
|
||||
|
||||
async function handleSave() {
|
||||
await updateMutation.mutateAsync({
|
||||
id: subtitle.id,
|
||||
patch: {
|
||||
language,
|
||||
release_name: releaseName,
|
||||
hearing_impaired: hearingImpaired,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit subtitle</SheetTitle>
|
||||
<SheetDescription>
|
||||
Update stored metadata for this subtitle record. File content is not replaced.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 px-1 py-2">
|
||||
<div className="surface-panel-subtle space-y-3 rounded-xl px-4 py-4">
|
||||
<div className="text-sm font-semibold">{subtitle.media_title || "Unknown media"}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||
languageChipClass(),
|
||||
)}
|
||||
>
|
||||
{subtitle.language.toUpperCase()} · {getLanguageName(subtitle.language)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||
providerBadgeClass(subtitle.provider),
|
||||
)}
|
||||
>
|
||||
{providerLabel(subtitle.provider)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle-language">Language</Label>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger id="subtitle-language">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.code.toUpperCase()} · {lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{languageChanged && (
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
Updates stored language label; file content unchanged.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle-release-name">Release name</Label>
|
||||
<Input
|
||||
id="subtitle-release-name"
|
||||
value={releaseName}
|
||||
onChange={(event) => setReleaseName(event.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 flex items-center justify-between rounded-xl border px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="subtitle-hearing-impaired">Hearing impaired</Label>
|
||||
<p className="text-muted-foreground text-xs">Marks this track as SDH/CC.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="subtitle-hearing-impaired"
|
||||
checked={hearingImpaired}
|
||||
onCheckedChange={setHearingImpaired}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="mt-auto gap-2 sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={updateMutation.isPending} onClick={() => void handleSave()}>
|
||||
Save changes
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LANGUAGES } from "@/player/utils/languageNames";
|
||||
import { SUBTITLE_PROVIDER_OPTIONS } from "./subtitleAdminStyles";
|
||||
|
||||
const ALL = "all";
|
||||
|
||||
interface AdminSubtitlesFiltersProps {
|
||||
provider: string;
|
||||
language: string;
|
||||
userId: string;
|
||||
search: string;
|
||||
users: Array<{ id: number; username: string }>;
|
||||
onProviderChange: (value: string) => void;
|
||||
onLanguageChange: (value: string) => void;
|
||||
onUserChange: (value: string) => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export default function AdminSubtitlesFilters({
|
||||
provider,
|
||||
language,
|
||||
userId,
|
||||
search,
|
||||
users,
|
||||
onProviderChange,
|
||||
onLanguageChange,
|
||||
onUserChange,
|
||||
onSearchChange,
|
||||
onReset,
|
||||
}: AdminSubtitlesFiltersProps) {
|
||||
return (
|
||||
<div className="surface-panel rounded-2xl border-0 px-3 py-3 sm:px-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search release name…"
|
||||
className="font-mono text-xs sm:max-w-sm"
|
||||
aria-label="Search subtitle release name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
role="group"
|
||||
aria-label="Filter by subtitle provider"
|
||||
>
|
||||
{SUBTITLE_PROVIDER_OPTIONS.map((option) => {
|
||||
const active = provider === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onProviderChange(option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary/40 bg-primary/15 text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.06)]"
|
||||
: "border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={language} onValueChange={onLanguageChange}>
|
||||
<SelectTrigger className="w-[180px]" aria-label="Filter by language">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All languages</SelectItem>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={userId} onValueChange={onUserChange}>
|
||||
<SelectTrigger className="w-[200px]" aria-label="Filter by uploader">
|
||||
<SelectValue placeholder="Uploader" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All uploaders</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={String(user.id)}>
|
||||
{user.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onReset}>
|
||||
Reset filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ALL as FILTER_ALL };
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { AdminDownloadedSubtitle } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { downloadAdminSubtitle } from "@/hooks/queries/admin/subtitles";
|
||||
import { getLanguageName } from "@/player/utils/languageNames";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Download, Ear, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import AdminSubtitleEditSheet from "./AdminSubtitleEditSheet";
|
||||
import {
|
||||
basenameFromPath,
|
||||
formatChipClass,
|
||||
languageChipClass,
|
||||
providerBadgeClass,
|
||||
providerLabel,
|
||||
staggerRowClass,
|
||||
} from "./subtitleAdminStyles";
|
||||
|
||||
interface AdminSubtitlesTableProps {
|
||||
subtitles: AdminDownloadedSubtitle[];
|
||||
hasActiveFilters: boolean;
|
||||
onResetFilters: () => void;
|
||||
onDelete: (subtitle: AdminDownloadedSubtitle) => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function formatRelative(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const deltaMs = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(deltaMs / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function AdminSubtitlesTable({
|
||||
subtitles,
|
||||
hasActiveFilters,
|
||||
onResetFilters,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: AdminSubtitlesTableProps) {
|
||||
const [editTarget, setEditTarget] = useState<AdminDownloadedSubtitle | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminDownloadedSubtitle | null>(null);
|
||||
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||
|
||||
async function handleDownload(subtitle: AdminDownloadedSubtitle) {
|
||||
setDownloadingId(subtitle.id);
|
||||
try {
|
||||
await downloadAdminSubtitle(subtitle);
|
||||
toast.success("Subtitle downloaded");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download subtitle");
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitles.length === 0) {
|
||||
return (
|
||||
<div className="surface-panel rounded-2xl border-0 px-6 py-16 text-center">
|
||||
<div className="caption-empty-state mx-auto mb-5 max-w-md space-y-1.5">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{hasActiveFilters ? "No subtitles match these filters" : "No stored subtitles yet"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-lg text-sm leading-relaxed">
|
||||
{hasActiveFilters
|
||||
? "Try widening the provider, language, or uploader filters to see more results."
|
||||
: "User uploads and provider downloads will appear here once subtitles are stored in S3."}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button type="button" variant="outline" className="mt-5" onClick={onResetFilters}>
|
||||
Reset filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Media</TableHead>
|
||||
<TableHead>File</TableHead>
|
||||
<TableHead>Language</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Release</TableHead>
|
||||
<TableHead>Format</TableHead>
|
||||
<TableHead className="w-10">HI</TableHead>
|
||||
<TableHead>Uploader</TableHead>
|
||||
<TableHead>Added</TableHead>
|
||||
<TableHead className="w-[120px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subtitles.map((subtitle, index) => (
|
||||
<TableRow key={subtitle.id} className={cn("group", staggerRowClass(index))}>
|
||||
<TableCell className="max-w-[220px]">
|
||||
<div className="space-y-1">
|
||||
{subtitle.media_content_id ? (
|
||||
<Link
|
||||
to={`/item/${encodeURIComponent(subtitle.media_content_id)}`}
|
||||
className="hover:text-primary line-clamp-2 font-semibold transition-colors hover:underline"
|
||||
>
|
||||
{subtitle.media_title || subtitle.media_content_id}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="line-clamp-2 font-semibold">
|
||||
{subtitle.media_title || "Unknown media"}
|
||||
</div>
|
||||
)}
|
||||
{subtitle.media_type === "episode" && (
|
||||
<Badge variant="outline" className="text-[10px] tracking-[0.12em] uppercase">
|
||||
Episode
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-muted-foreground max-w-[180px] truncate font-mono text-xs"
|
||||
title={subtitle.file_path}
|
||||
>
|
||||
{basenameFromPath(subtitle.file_path)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||
languageChipClass(),
|
||||
)}
|
||||
>
|
||||
<span className="font-semibold tracking-[0.08em] uppercase">
|
||||
{subtitle.language}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{getLanguageName(subtitle.language)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||
providerBadgeClass(subtitle.provider),
|
||||
)}
|
||||
>
|
||||
{providerLabel(subtitle.provider)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[200px] truncate font-mono text-xs"
|
||||
title={subtitle.release_name}
|
||||
>
|
||||
{subtitle.release_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn("inline-flex rounded px-2 py-0.5", formatChipClass())}>
|
||||
.{subtitle.format}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subtitle.hearing_impaired ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-200">
|
||||
<Ear className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
HI
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{subtitle.uploader_username || "—"}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm" title={subtitle.created_at}>
|
||||
{formatRelative(subtitle.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Edit subtitle ${subtitle.id}`}
|
||||
onClick={() => setEditTarget(subtitle)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Download subtitle ${subtitle.id}`}
|
||||
disabled={downloadingId === subtitle.id}
|
||||
onClick={() => void handleDownload(subtitle)}
|
||||
>
|
||||
{downloadingId === subtitle.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive h-8 w-8"
|
||||
aria-label={`Delete subtitle ${subtitle.id}`}
|
||||
onClick={() => setDeleteTarget(subtitle)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AdminSubtitleEditSheet
|
||||
subtitle={editTarget}
|
||||
open={editTarget != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
title="Delete subtitle?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `Remove ${providerLabel(deleteTarget.provider)} ${deleteTarget.language.toUpperCase()} subtitles for "${deleteTarget.media_title || "this media"}"? This deletes the stored file from S3.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
isPending={isDeleting}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
onDelete(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SUBTITLE_PROVIDER_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "upload", label: "Upload" },
|
||||
{ value: "opensubtitles", label: "OpenSubtitles" },
|
||||
{ value: "subdl", label: "SubDL" },
|
||||
{ value: "subsource", label: "SubSource" },
|
||||
] as const;
|
||||
|
||||
export function providerBadgeClass(provider: string): string {
|
||||
switch (provider) {
|
||||
case "upload":
|
||||
return "border-amber-500/35 bg-amber-500/12 text-amber-100";
|
||||
case "opensubtitles":
|
||||
return "border-sky-500/30 bg-sky-500/10 text-sky-100";
|
||||
case "subdl":
|
||||
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-100";
|
||||
case "subsource":
|
||||
return "border-violet-500/30 bg-violet-500/10 text-violet-100";
|
||||
default:
|
||||
return "border-border/70 bg-muted/40 text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
export function providerLabel(provider: string): string {
|
||||
return SUBTITLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider;
|
||||
}
|
||||
|
||||
export function languageChipClass(): string {
|
||||
return "border-primary/25 bg-primary/10 text-foreground";
|
||||
}
|
||||
|
||||
export function formatChipClass(): string {
|
||||
return "border-border/60 bg-muted/30 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground";
|
||||
}
|
||||
|
||||
export function staggerRowClass(index: number): string {
|
||||
const capped = Math.min(index, 8);
|
||||
return cn("motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300", {
|
||||
"motion-safe:delay-0": capped === 0,
|
||||
"motion-safe:delay-[40ms]": capped === 1,
|
||||
"motion-safe:delay-[80ms]": capped === 2,
|
||||
"motion-safe:delay-[120ms]": capped === 3,
|
||||
"motion-safe:delay-[160ms]": capped === 4,
|
||||
"motion-safe:delay-[200ms]": capped === 5,
|
||||
"motion-safe:delay-[240ms]": capped === 6,
|
||||
"motion-safe:delay-[280ms]": capped === 7,
|
||||
"motion-safe:delay-[320ms]": capped >= 8,
|
||||
});
|
||||
}
|
||||
|
||||
export function basenameFromPath(filePath: string): string {
|
||||
if (!filePath) return "—";
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
|
||||
import type { SubtitleLanguageDetection } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
|
||||
|
||||
const ACCEPTED_SUBTITLE_EXTENSIONS = ".srt,.vtt,.ass,.ssa,.sub";
|
||||
const ACCEPTED_SUBTITLE_EXTENSION_LIST = ["srt", "vtt", "ass", "ssa", "sub"] as const;
|
||||
|
||||
export interface SubtitleUploadInput {
|
||||
mediaFileId: number;
|
||||
file: File;
|
||||
language?: string;
|
||||
languageOverride?: boolean;
|
||||
hearingImpaired: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleUploadFormProps {
|
||||
mediaFileId: number;
|
||||
upload: (input: SubtitleUploadInput) => Promise<void>;
|
||||
detectLanguage?: (file: File, fallbackLanguage?: string) => Promise<SubtitleLanguageDetection>;
|
||||
onSuccess: () => void;
|
||||
onError?: (message: string) => void;
|
||||
variant?: "player" | "default";
|
||||
defaultLanguage?: string;
|
||||
}
|
||||
|
||||
function isAcceptedSubtitleFile(file: File): boolean {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return ACCEPTED_SUBTITLE_EXTENSION_LIST.includes(
|
||||
extension as (typeof ACCEPTED_SUBTITLE_EXTENSION_LIST)[number],
|
||||
);
|
||||
}
|
||||
|
||||
function detectionSourceLabel(source: SubtitleLanguageDetection["source"]): string {
|
||||
switch (source) {
|
||||
case "filename":
|
||||
return "filename";
|
||||
case "metadata":
|
||||
return "file metadata";
|
||||
case "content":
|
||||
return "subtitle text";
|
||||
case "manual":
|
||||
return "manual selection";
|
||||
default:
|
||||
return "detection";
|
||||
}
|
||||
}
|
||||
|
||||
export function SubtitleUploadForm({
|
||||
mediaFileId,
|
||||
upload,
|
||||
detectLanguage,
|
||||
onSuccess,
|
||||
onError,
|
||||
variant = "default",
|
||||
defaultLanguage = "en",
|
||||
}: SubtitleUploadFormProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const detectRequestRef = useRef(0);
|
||||
const [language, setLanguage] = useState(defaultLanguage);
|
||||
const [hearingImpaired, setHearingImpaired] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [detectingLanguage, setDetectingLanguage] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [detectionSource, setDetectionSource] = useState<
|
||||
SubtitleLanguageDetection["source"] | null
|
||||
>(null);
|
||||
const [languageOverride, setLanguageOverride] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isPlayer = variant === "player";
|
||||
|
||||
const reportError = useCallback(
|
||||
(message: string) => {
|
||||
setError(message);
|
||||
onError?.(message);
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
const runLanguageDetection = useCallback(
|
||||
async (file: File, fallbackLanguage: string) => {
|
||||
if (!detectLanguage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++detectRequestRef.current;
|
||||
setDetectingLanguage(true);
|
||||
|
||||
try {
|
||||
const result = await detectLanguage(file, fallbackLanguage);
|
||||
if (requestId !== detectRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
if (result.language) {
|
||||
setLanguage(result.language);
|
||||
setDetectionSource(result.source);
|
||||
setLanguageOverride(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== detectRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setDetectionSource(null);
|
||||
reportError(err instanceof Error ? err.message : "Failed to detect subtitle language");
|
||||
} finally {
|
||||
if (requestId === detectRequestRef.current) {
|
||||
setDetectingLanguage(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[detectLanguage, reportError],
|
||||
);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(file: File | null | undefined) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!isAcceptedSubtitleFile(file)) {
|
||||
reportError("Unsupported file type. Use SRT, VTT, ASS, SSA, or SUB.");
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
void runLanguageDetection(file, language);
|
||||
},
|
||||
[language, reportError, runLanguageDetection],
|
||||
);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
selectFile(event.target.files?.[0]);
|
||||
};
|
||||
|
||||
const handleBrowseClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragging(false);
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
selectFile(file);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
setLanguage(value);
|
||||
setDetectionSource("manual");
|
||||
setLanguageOverride(true);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
reportError("Choose a subtitle file to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await upload({
|
||||
mediaFileId,
|
||||
file: selectedFile,
|
||||
language,
|
||||
languageOverride,
|
||||
hearingImpaired,
|
||||
});
|
||||
setSelectedFile(null);
|
||||
setDetectionSource(null);
|
||||
setLanguageOverride(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
reportError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
isPlayer ? "border-b border-white/10 px-4 py-3" : "rounded-xl border border-dashed p-4",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className={cn("text-sm font-medium", isPlayer ? "text-white" : "text-foreground")}>
|
||||
Upload subtitle
|
||||
</p>
|
||||
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
|
||||
Drag and drop or browse for SRT, VTT, ASS, SSA, or SUB files up to 5 MB. Language is
|
||||
detected automatically when possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_SUBTITLE_EXTENSIONS}
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Drop subtitle file here or browse"
|
||||
onClick={handleBrowseClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleBrowseClick();
|
||||
}
|
||||
}}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors",
|
||||
isPlayer
|
||||
? isDragging
|
||||
? "border-white/50 bg-white/10"
|
||||
: "border-white/20 bg-white/5 hover:border-white/35 hover:bg-white/10"
|
||||
: isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/70 bg-muted/20 hover:border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Upload
|
||||
className={cn("size-5", isPlayer ? "text-white/70" : "text-muted-foreground")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className={cn("text-sm font-medium", isPlayer ? "text-white" : "text-foreground")}>
|
||||
{isDragging ? "Drop subtitle file" : "Drag and drop a subtitle file"}
|
||||
</p>
|
||||
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
|
||||
or click to browse
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-2", !isPlayer && "sm:flex-row sm:items-center")}>
|
||||
<div className={cn("space-y-1", !isPlayer && "w-full sm:w-[220px]")}>
|
||||
{isPlayer ? (
|
||||
<select
|
||||
aria-label="Upload language"
|
||||
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
|
||||
value={language}
|
||||
onChange={(event) => handleLanguageChange(event.target.value)}
|
||||
disabled={detectingLanguage}
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Select
|
||||
value={language}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={detectingLanguage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{detectingLanguage ? (
|
||||
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
|
||||
Detecting language…
|
||||
</p>
|
||||
) : detectionSource && detectionSource !== "manual" ? (
|
||||
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
|
||||
Detected {getLanguageName(language)} from {detectionSourceLabel(detectionSource)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isPlayer ? (
|
||||
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hearingImpaired}
|
||||
onChange={(event) => setHearingImpaired(event.target.checked)}
|
||||
className="rounded border-white/20 bg-neutral-800"
|
||||
/>
|
||||
Hearing impaired (HI)
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={`subtitle-upload-hi-${mediaFileId}`}
|
||||
checked={hearingImpaired}
|
||||
onCheckedChange={setHearingImpaired}
|
||||
/>
|
||||
<Label htmlFor={`subtitle-upload-hi-${mediaFileId}`} className="text-sm font-normal">
|
||||
Hearing impaired (HI)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPlayer ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded bg-white/10 px-3 py-1.5 text-sm font-medium hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || detectingLanguage || !selectedFile}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || detectingLanguage || !selectedFile}
|
||||
className="sm:ml-auto"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<p className={cn("truncate text-xs", isPlayer ? "text-white/60" : "text-muted-foreground")}>
|
||||
Selected: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"rounded px-3 py-2 text-xs",
|
||||
isPlayer
|
||||
? "bg-red-900/40 text-red-300"
|
||||
: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { api, apiDownload } from "@/api/client";
|
||||
import type {
|
||||
AdminDownloadedSubtitle,
|
||||
AdminDownloadedSubtitlesFilters,
|
||||
AdminDownloadedSubtitlesResponse,
|
||||
AdminUpdateDownloadedSubtitleRequest,
|
||||
SubtitleProviderConfig,
|
||||
SubtitleProviderUpdateRequest,
|
||||
SubtitleProviderTestResponse,
|
||||
@@ -10,6 +14,71 @@ import { toast } from "sonner";
|
||||
|
||||
const ADMIN_STALE_TIME = 30_000;
|
||||
|
||||
function buildDownloadedSubtitlesQuery(filters: AdminDownloadedSubtitlesFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.provider) params.set("provider", filters.provider);
|
||||
if (filters.language) params.set("language", filters.language);
|
||||
if (filters.userId != null) params.set("user_id", String(filters.userId));
|
||||
if (filters.mediaFileId != null) params.set("media_file_id", String(filters.mediaFileId));
|
||||
if (filters.q) params.set("q", filters.q);
|
||||
params.set("limit", String(filters.limit ?? 50));
|
||||
params.set("offset", String(filters.offset ?? 0));
|
||||
const query = params.toString();
|
||||
return query ? `/admin/subtitles?${query}` : "/admin/subtitles";
|
||||
}
|
||||
|
||||
export function useAdminDownloadedSubtitles(filters: AdminDownloadedSubtitlesFilters) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.downloadedSubtitles(filters),
|
||||
queryFn: () =>
|
||||
api<AdminDownloadedSubtitlesResponse>(buildDownloadedSubtitlesQuery(filters)).then(
|
||||
(data) => data ?? { subtitles: [], total: 0, uploads: 0, provider_downloads: 0 },
|
||||
),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateDownloadedSubtitle() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: number; patch: AdminUpdateDownloadedSubtitleRequest }) =>
|
||||
api<{ subtitle: AdminDownloadedSubtitle }>(`/admin/subtitles/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Subtitle updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update subtitle");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteDownloadedSubtitle() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
api<void>(`/admin/subtitles/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Subtitle deleted");
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete subtitle");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadAdminSubtitle(subtitle: AdminDownloadedSubtitle): Promise<void> {
|
||||
const base = subtitle.release_name?.trim() || `subtitle-${subtitle.id}`;
|
||||
const filename = base.includes(".") ? base : `${base}.${subtitle.format}`;
|
||||
await apiDownload(`/admin/subtitles/${subtitle.id}/download`, filename);
|
||||
}
|
||||
|
||||
export function useSubtitleProviders() {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.subtitleProviders(),
|
||||
|
||||
@@ -360,6 +360,15 @@ export const adminKeys = {
|
||||
operationalLogs: (params: Record<string, unknown>) => ["admin", "logs", "app", params] as const,
|
||||
auditLogs: (params: Record<string, unknown>) => ["admin", "logs", "audit", params] as const,
|
||||
subtitleProviders: () => ["admin", "subtitleProviders"] as const,
|
||||
downloadedSubtitles: (params: {
|
||||
provider?: string;
|
||||
language?: string;
|
||||
userId?: number;
|
||||
mediaFileId?: number;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => ["admin", "downloadedSubtitles", params] as const,
|
||||
historyImportSources: () => ["admin", "historyImportSources"] as const,
|
||||
historyImportExternalUsers: (sourceId: number) =>
|
||||
["admin", "historyImportSources", sourceId, "users"] as const,
|
||||
|
||||
@@ -5,8 +5,10 @@ import { api } from "@/api/client";
|
||||
import type {
|
||||
DownloadedSubtitle,
|
||||
SubtitleDownloadRequest,
|
||||
SubtitleLanguageDetection,
|
||||
SubtitleSearchRequest,
|
||||
SubtitleSearchResponse,
|
||||
SubtitleUploadRequest,
|
||||
} from "@/api/types";
|
||||
|
||||
import { subtitleKeys } from "./keys";
|
||||
@@ -15,6 +17,34 @@ interface DownloadSubtitleResponse {
|
||||
subtitle: DownloadedSubtitle;
|
||||
}
|
||||
|
||||
function buildSubtitleUploadFormData(request: SubtitleUploadRequest): FormData {
|
||||
const form = new FormData();
|
||||
form.set("media_file_id", String(request.media_file_id));
|
||||
if (request.language) {
|
||||
form.set("language", request.language);
|
||||
}
|
||||
if (request.language_override) {
|
||||
form.set("language_override", "true");
|
||||
}
|
||||
form.set("file", request.file);
|
||||
if (request.release_name) {
|
||||
form.set("release_name", request.release_name);
|
||||
}
|
||||
if (request.hearing_impaired) {
|
||||
form.set("hearing_impaired", "true");
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildSubtitleDetectFormData(file: File, language?: string): FormData {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
if (language) {
|
||||
form.set("language", language);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export async function fetchDownloadedSubtitles(
|
||||
mediaFileId: number,
|
||||
options?: RequestInit,
|
||||
@@ -48,6 +78,29 @@ export async function downloadSubtitle(
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadSubtitle(
|
||||
request: SubtitleUploadRequest,
|
||||
options?: RequestInit,
|
||||
): Promise<DownloadSubtitleResponse> {
|
||||
return api<DownloadSubtitleResponse>("/subtitles/upload", {
|
||||
...options,
|
||||
method: "POST",
|
||||
body: buildSubtitleUploadFormData(request),
|
||||
});
|
||||
}
|
||||
|
||||
export async function detectSubtitleLanguage(
|
||||
file: File,
|
||||
language?: string,
|
||||
options?: RequestInit,
|
||||
): Promise<SubtitleLanguageDetection> {
|
||||
return api<SubtitleLanguageDetection>("/subtitles/detect-language", {
|
||||
...options,
|
||||
method: "POST",
|
||||
body: buildSubtitleDetectFormData(file, language),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDownloadedSubtitles(mediaFileId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: mediaFileId != null ? subtitleKeys.downloaded(mediaFileId) : subtitleKeys.all,
|
||||
@@ -72,3 +125,20 @@ export function useDownloadSubtitle() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadSubtitle() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: SubtitleUploadRequest) => uploadSubtitle(request),
|
||||
onSuccess: async (_response, request) => {
|
||||
toast.success("Subtitle uploaded");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: subtitleKeys.downloaded(request.media_file_id),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to upload subtitle");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const ADMIN_TITLES: Record<string, string> = {
|
||||
recommendations: "Admin Recommendations",
|
||||
requests: "Admin Requests",
|
||||
sections: "Admin Sections",
|
||||
subtitles: "Admin Subtitles",
|
||||
settings: "Admin Settings",
|
||||
tasks: "Admin Tasks",
|
||||
users: "Admin Users",
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { AdminDownloadedSubtitle } from "@/api/types";
|
||||
import AdminSubtitlesFilters, {
|
||||
FILTER_ALL,
|
||||
} from "@/components/admin/subtitles/AdminSubtitlesFilters";
|
||||
import AdminSubtitlesTable from "@/components/admin/subtitles/AdminSubtitlesTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useAdminDeleteDownloadedSubtitle,
|
||||
useAdminDownloadedSubtitles,
|
||||
} from "@/hooks/queries/admin/subtitles";
|
||||
import { useAdminUsers } from "@/hooks/queries/admin/users";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
|
||||
|
||||
export default function AdminSubtitles() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: users = [] } = useAdminUsers();
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const deleteMutation = useAdminDeleteDownloadedSubtitle();
|
||||
|
||||
const provider = searchParams.get("provider") ?? FILTER_ALL;
|
||||
const language = searchParams.get("language") ?? FILTER_ALL;
|
||||
const userId = searchParams.get("user_id") ?? FILTER_ALL;
|
||||
const search = searchParams.get("q") ?? "";
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
provider: provider !== FILTER_ALL ? provider : undefined,
|
||||
language: language !== FILTER_ALL ? language : undefined,
|
||||
userId: userId !== FILTER_ALL ? Number(userId) : undefined,
|
||||
q: search.trim() || undefined,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
}),
|
||||
[language, page, pageSize, provider, search, userId],
|
||||
);
|
||||
|
||||
const subtitlesQuery = useAdminDownloadedSubtitles(filters);
|
||||
const subtitles = subtitlesQuery.data?.subtitles ?? [];
|
||||
const total = subtitlesQuery.data?.total ?? 0;
|
||||
const uploads = subtitlesQuery.data?.uploads ?? 0;
|
||||
const providerDownloads = subtitlesQuery.data?.provider_downloads ?? 0;
|
||||
const languageCount = new Set(subtitles.map((row) => row.language)).size;
|
||||
|
||||
const hasActiveFilters =
|
||||
provider !== FILTER_ALL ||
|
||||
language !== FILTER_ALL ||
|
||||
userId !== FILTER_ALL ||
|
||||
search.trim().length > 0;
|
||||
|
||||
function updateFilter(key: string, value: string) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value === FILTER_ALL || value.trim() === "") {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
setPage(0);
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setPage(0);
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}
|
||||
|
||||
function handleDelete(subtitle: AdminDownloadedSubtitle) {
|
||||
deleteMutation.mutate(subtitle.id);
|
||||
}
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const canPrev = page > 0;
|
||||
const canNext = (page + 1) * pageSize < total;
|
||||
|
||||
if (subtitlesQuery.isLoading) {
|
||||
return (
|
||||
<div className="page-shell space-y-6 py-4 sm:py-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-72 rounded-lg" />
|
||||
<Skeleton className="h-5 w-full max-w-xl rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-shell space-y-6 py-4 sm:py-6">
|
||||
<div className="page-header gap-5">
|
||||
<div className="space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Subtitles</h1>
|
||||
<p className="page-subtitle text-sm sm:text-base">
|
||||
Manage stored subtitle files across the library — user uploads and provider downloads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface-panel-subtle grid gap-4 rounded-2xl px-4 py-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatBlock label="Total stored" value={total} />
|
||||
<StatBlock label="User uploads" value={uploads} />
|
||||
<StatBlock label="Provider downloads" value={providerDownloads} />
|
||||
<StatBlock label="Languages on page" value={languageCount} />
|
||||
</div>
|
||||
|
||||
<AdminSubtitlesFilters
|
||||
provider={provider}
|
||||
language={language}
|
||||
userId={userId}
|
||||
search={search}
|
||||
users={users}
|
||||
onProviderChange={(value) => updateFilter("provider", value)}
|
||||
onLanguageChange={(value) => updateFilter("language", value)}
|
||||
onUserChange={(value) => updateFilter("user_id", value)}
|
||||
onSearchChange={(value) => updateFilter("q", value)}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
<AdminSubtitlesTable
|
||||
subtitles={subtitles}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onResetFilters={resetFilters}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
{total > 0 && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number(value));
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={size}>
|
||||
{size} / page
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canPrev}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-muted-foreground px-1 text-sm">
|
||||
Page {page + 1} of {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canNext}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBlock({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium tracking-[0.18em] uppercase">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
searchSubtitles,
|
||||
detectSubtitleLanguage,
|
||||
useDownloadSubtitle,
|
||||
useDownloadedSubtitles,
|
||||
useUploadSubtitle,
|
||||
} from "@/hooks/queries/subtitles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
|
||||
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
|
||||
import { buildQualitySummary } from "./VersionFlyout";
|
||||
|
||||
interface SubtitleSearchDialogProps {
|
||||
@@ -39,6 +42,7 @@ const providerInfo: Record<string, { abbr: string; className: string }> = {
|
||||
opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" },
|
||||
subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" },
|
||||
subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" },
|
||||
upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" },
|
||||
};
|
||||
|
||||
function scoreTone(score: number): { text: string; ring: string; bg: string } {
|
||||
@@ -77,6 +81,7 @@ export default function SubtitleSearchDialog({
|
||||
title,
|
||||
}: SubtitleSearchDialogProps) {
|
||||
const downloadSubtitleMutation = useDownloadSubtitle();
|
||||
const uploadSubtitleMutation = useUploadSubtitle();
|
||||
const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined);
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -190,13 +195,41 @@ export default function SubtitleSearchDialog({
|
||||
[downloadSubtitleMutation, downloadedQuery, version],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (input: {
|
||||
mediaFileId: number;
|
||||
file: File;
|
||||
language?: string;
|
||||
languageOverride?: boolean;
|
||||
hearingImpaired: boolean;
|
||||
}) => {
|
||||
await uploadSubtitleMutation.mutateAsync({
|
||||
media_file_id: input.mediaFileId,
|
||||
file: input.file,
|
||||
language: input.language,
|
||||
language_override: input.languageOverride,
|
||||
hearing_impaired: input.hearingImpaired,
|
||||
});
|
||||
},
|
||||
[uploadSubtitleMutation],
|
||||
);
|
||||
|
||||
const handleDetectLanguage = useCallback(
|
||||
(file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleUploadSuccess = useCallback(async () => {
|
||||
await downloadedQuery.refetch();
|
||||
}, [downloadedQuery]);
|
||||
|
||||
const versionLabel = version ? buildQualitySummary(version) : "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl overflow-hidden sm:max-w-3xl">
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>Search Subtitles</DialogTitle>
|
||||
<DialogTitle>Add Subtitles</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{title}
|
||||
{versionLabel ? ` \u00B7 ${versionLabel}` : ""}
|
||||
@@ -205,28 +238,42 @@ export default function SubtitleSearchDialog({
|
||||
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
|
||||
<SelectTrigger className="w-full sm:w-[220px]">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{version && (
|
||||
<SubtitleUploadForm
|
||||
mediaFileId={version.file_id}
|
||||
upload={handleUpload}
|
||||
detectLanguage={handleDetectLanguage}
|
||||
onSuccess={handleUploadSuccess}
|
||||
onError={setSearchError}
|
||||
defaultLanguage={selectedLanguage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSearch} disabled={!version || searching}>
|
||||
{searching ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4" />
|
||||
)}
|
||||
Search
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Search online</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
|
||||
<SelectTrigger className="w-full sm:w-[220px]">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={handleSearch} disabled={!version || searching}>
|
||||
{searching ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4" />
|
||||
)}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchError && (
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { PlayerConfig } from "../context/PlayerConfigContext";
|
||||
import { playerFetch } from "../player-fetch";
|
||||
import type { SubtitleSearchResponse, SubtitleResult } from "@/api/types";
|
||||
import type {
|
||||
SubtitleLanguageDetection,
|
||||
SubtitleSearchResponse,
|
||||
SubtitleResult,
|
||||
} from "@/api/types";
|
||||
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
|
||||
import { LANGUAGES } from "../utils/languageNames";
|
||||
|
||||
interface SubtitleSearchModalProps {
|
||||
@@ -22,6 +27,7 @@ const providerInfo: Record<string, ProviderInfo> = {
|
||||
opensubtitles: { abbr: "OS", color: "#eab308" },
|
||||
subdl: { abbr: "SDL", color: "#3b82f6" },
|
||||
subsource: { abbr: "SS", color: "#ef4444" },
|
||||
upload: { abbr: "UP", color: "#a855f7" },
|
||||
};
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
@@ -132,6 +138,56 @@ export function SubtitleSearchModal({
|
||||
}
|
||||
}, [playerConfig, mediaFileId, selectedLang]);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (input: {
|
||||
mediaFileId: number;
|
||||
file: File;
|
||||
language?: string;
|
||||
languageOverride?: boolean;
|
||||
hearingImpaired: boolean;
|
||||
}) => {
|
||||
const form = new FormData();
|
||||
form.set("media_file_id", String(input.mediaFileId));
|
||||
if (input.language) {
|
||||
form.set("language", input.language);
|
||||
}
|
||||
if (input.languageOverride) {
|
||||
form.set("language_override", "true");
|
||||
}
|
||||
form.set("file", input.file);
|
||||
if (input.hearingImpaired) {
|
||||
form.set("hearing_impaired", "true");
|
||||
}
|
||||
|
||||
await playerFetch(playerConfig, "/subtitles/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
[playerConfig],
|
||||
);
|
||||
|
||||
const handleDetectLanguage = useCallback(
|
||||
async (file: File, fallbackLanguage?: string): Promise<SubtitleLanguageDetection> => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
if (fallbackLanguage) {
|
||||
form.set("language", fallbackLanguage);
|
||||
}
|
||||
|
||||
return playerFetch<SubtitleLanguageDetection>(playerConfig, "/subtitles/detect-language", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
[playerConfig],
|
||||
);
|
||||
|
||||
const handleUploadSuccess = useCallback(() => {
|
||||
onSubtitleDownloaded();
|
||||
handleClose();
|
||||
}, [onSubtitleDownloaded, handleClose]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleResult) => {
|
||||
const key = `${result.provider}:${result.id}`;
|
||||
@@ -171,7 +227,7 @@ export function SubtitleSearchModal({
|
||||
onClick={handleClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Subtitle Search"
|
||||
aria-label="Add Subtitles"
|
||||
onKeyDown={handleFocusTrap}
|
||||
>
|
||||
<div
|
||||
@@ -181,7 +237,7 @@ export function SubtitleSearchModal({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Search Subtitles</h2>
|
||||
<h2 className="text-sm font-semibold">Add Subtitles</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded text-white/60 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
|
||||
@@ -192,6 +248,20 @@ export function SubtitleSearchModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SubtitleUploadForm
|
||||
mediaFileId={mediaFileId}
|
||||
upload={handleUpload}
|
||||
detectLanguage={handleDetectLanguage}
|
||||
onSuccess={handleUploadSuccess}
|
||||
onError={setError}
|
||||
variant="player"
|
||||
defaultLanguage={selectedLang}
|
||||
/>
|
||||
|
||||
<div className="border-b border-white/10 px-4 py-2">
|
||||
<p className="text-xs font-medium text-white/60">Search online</p>
|
||||
</div>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="flex gap-2 px-4 py-3">
|
||||
<select
|
||||
|
||||
@@ -33,9 +33,11 @@ export async function playerFetch<T>(
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const token = config.getAccessToken();
|
||||
if (token) {
|
||||
|
||||
Reference in New Issue
Block a user