327 lines
10 KiB
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)
|
|
}
|