Files
silo-server/internal/api/handlers/admin_downloaded_subtitles.go

327 lines
10 KiB
Go

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)
}