Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts: # go.sum
This commit is contained in:
@@ -112,19 +112,20 @@ func NewAdminHandler(
|
||||
|
||||
// createUserRequest represents the JSON body for POST /admin/users.
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
type updateLibraryIDsField struct {
|
||||
@@ -149,20 +150,43 @@ func (f updateLibraryIDsField) Ptr() *[]int {
|
||||
return &value
|
||||
}
|
||||
|
||||
type updateStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
func (f updateStringSliceField) Ptr() *[]string {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := append([]string(nil), f.Value...)
|
||||
return &value
|
||||
}
|
||||
|
||||
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
|
||||
type updateUserRequest struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Permissions updateStringSliceField `json:"permissions,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserResponse represents a user in admin JSON responses.
|
||||
@@ -171,6 +195,7 @@ type adminUserResponse struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
@@ -234,6 +259,7 @@ func toAdminUserResponse(u *models.User) adminUserResponse {
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
Permissions: append([]string{}, u.Permissions...),
|
||||
Enabled: u.Enabled,
|
||||
LibraryIDs: append([]int(nil), u.LibraryIDs...),
|
||||
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
|
||||
@@ -361,6 +387,11 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
|
||||
User: models.CreateUserInput{
|
||||
@@ -368,6 +399,7 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
@@ -418,12 +450,22 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
var permissions *[]string
|
||||
if req.Permissions.Set {
|
||||
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
permissions = &normalized
|
||||
}
|
||||
|
||||
err = h.userRepo.Update(r.Context(), id, models.UpdateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
Enabled: req.Enabled,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
@@ -710,6 +752,7 @@ func updateRequiresSessionRevocation(req updateUserRequest) bool {
|
||||
req.Role != nil ||
|
||||
req.Enabled != nil ||
|
||||
req.LibraryIDs.Set ||
|
||||
req.Permissions.Set ||
|
||||
req.MaxPlaybackQuality != nil
|
||||
}
|
||||
|
||||
@@ -901,7 +944,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
|
||||
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil))
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
|
||||
}
|
||||
|
||||
// UpdateItemMetadataRequest contains the fields that can be updated via
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
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/models"
|
||||
)
|
||||
|
||||
@@ -110,7 +111,13 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminJobToResponse(r, job, h.store))
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if !canReadAdminJob(claims, job) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
|
||||
}
|
||||
|
||||
func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse {
|
||||
@@ -147,6 +154,96 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
|
||||
return resp
|
||||
}
|
||||
|
||||
func adminJobToResponseForClaims(
|
||||
r *http.Request,
|
||||
job *models.AdminJob,
|
||||
store AdminJobArtifactStore,
|
||||
claims *auth.Claims,
|
||||
) adminJobResponse {
|
||||
response := adminJobToResponse(r, job, store)
|
||||
sanitizeAdminJobResponseForClaims(&response, claims)
|
||||
return response
|
||||
}
|
||||
|
||||
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
|
||||
if response == nil || (claims != nil && claims.Role == "admin") {
|
||||
return
|
||||
}
|
||||
response.RequestPayload = json.RawMessage(`{}`)
|
||||
response.ResultPayload = sanitizeNonAdminAdminJobResultPayload(response.JobType, response.ResultPayload)
|
||||
response.ErrorMessage = ""
|
||||
response.PublicURL = ""
|
||||
response.DownloadURL = ""
|
||||
response.DownloadExpiresAt = nil
|
||||
}
|
||||
|
||||
func sanitizeNonAdminAdminJobResultPayload(jobType string, payload json.RawMessage) json.RawMessage {
|
||||
if jobType != adminjob.JobTypeItemRefresh {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if len(payload) == 0 || json.Unmarshal(payload, &raw) != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
safe := make(map[string]json.RawMessage)
|
||||
copyJSONFields(safe, raw,
|
||||
"requested_content_id",
|
||||
"refresh_content_id",
|
||||
"detail_content_id",
|
||||
"matched_files",
|
||||
)
|
||||
if scanPayload, ok := raw["scan_result"]; ok {
|
||||
if scanSummary := sanitizeScanResultPayload(scanPayload); len(scanSummary) > 0 {
|
||||
safe["scan_result"] = scanSummary
|
||||
}
|
||||
}
|
||||
if len(safe) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
data, err := json.Marshal(safe)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func sanitizeScanResultPayload(payload json.RawMessage) json.RawMessage {
|
||||
var raw map[string]json.RawMessage
|
||||
if len(payload) == 0 || json.Unmarshal(payload, &raw) != nil {
|
||||
return nil
|
||||
}
|
||||
safe := make(map[string]json.RawMessage)
|
||||
copyJSONFields(safe, raw,
|
||||
"New",
|
||||
"Updated",
|
||||
"Unchanged",
|
||||
"Missing",
|
||||
"FilesDeleted",
|
||||
"MembershipsRemoved",
|
||||
"ItemsDeleted",
|
||||
"Errors",
|
||||
"EmptyRootGuarded",
|
||||
)
|
||||
if len(safe) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(safe)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func copyJSONFields(dst, src map[string]json.RawMessage, keys ...string) {
|
||||
for _, key := range keys {
|
||||
if value, ok := src[key]; ok && len(value) > 0 {
|
||||
dst[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeAdminJobConflict(w http.ResponseWriter, message string, job *models.AdminJob, handler *AdminJobsHandler, r *http.Request) {
|
||||
resp := adminJobConflictResponse{
|
||||
Error: "conflict",
|
||||
@@ -176,3 +273,13 @@ func currentAdminUserID(r *http.Request) int {
|
||||
}
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
|
||||
if claims == nil || job == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 1, Role: "admin"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if !canReadAdminJob(claims, job) {
|
||||
t.Fatal("admin should be allowed to read any job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if !canReadAdminJob(claims, job) {
|
||||
t.Fatal("creator should be allowed to read own item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if canReadAdminJob(claims, job) {
|
||||
t.Fatal("non-admin should not read non-item-refresh jobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 3, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if canReadAdminJob(claims, job) {
|
||||
t.Fatal("non-admin should not read another user's item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{
|
||||
JobType: adminjob.JobTypeItemRefresh,
|
||||
CreatedByUserID: 2,
|
||||
RequestPayload: json.RawMessage(
|
||||
`{"requested_content_id":"item-1","scan_path":"/srv/media/private/movie"}`,
|
||||
),
|
||||
ResultPayload: json.RawMessage(
|
||||
`{"requested_content_id":"item-1","detail_content_id":"item-2","scan_path":"/srv/media/private/movie","scan_result":{"New":1,"RootObservations":[{"RootPath":"/srv/media/private","SampleFilePath":"/srv/media/private/movie.mkv"}]}}`,
|
||||
),
|
||||
ErrorMessage: "scan scope: stat /srv/media/private/movie: permission denied",
|
||||
PublicURL: "https://example.test/public",
|
||||
}
|
||||
|
||||
resp := adminJobToResponseForClaims(nil, job, nil, claims)
|
||||
|
||||
if string(resp.RequestPayload) != `{}` {
|
||||
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
|
||||
}
|
||||
if resp.PublicURL != "" || resp.DownloadURL != "" || resp.DownloadExpiresAt != nil {
|
||||
t.Fatalf("expected non-admin URLs to be stripped, got public=%q download=%q", resp.PublicURL, resp.DownloadURL)
|
||||
}
|
||||
if bytes.Contains(resp.ResultPayload, []byte("/srv/media")) ||
|
||||
bytes.Contains(resp.ResultPayload, []byte("scan_path")) ||
|
||||
bytes.Contains(resp.ResultPayload, []byte("RootObservations")) ||
|
||||
bytes.Contains(resp.ResultPayload, []byte("SampleFilePath")) {
|
||||
t.Fatalf("ResultPayload leaked sensitive data: %s", resp.ResultPayload)
|
||||
}
|
||||
if !bytes.Contains(resp.ResultPayload, []byte("requested_content_id")) ||
|
||||
!bytes.Contains(resp.ResultPayload, []byte("detail_content_id")) {
|
||||
t.Fatalf("ResultPayload = %s, want safe item refresh summary fields", resp.ResultPayload)
|
||||
}
|
||||
if resp.ErrorMessage != "" {
|
||||
t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -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,73 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
role := "admin"
|
||||
enabled := true
|
||||
libraryIDs := []int{1, 2}
|
||||
maxPlaybackQuality := "1080p"
|
||||
password := "new-password"
|
||||
username := "renamed"
|
||||
maxStreams := 4
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req updateUserRequest
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "permissions set",
|
||||
req: updateUserRequest{Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permissions unset",
|
||||
req: updateUserRequest{Permissions: updateStringSliceField{Set: false, Value: []string{"metadata_curation"}}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
req: updateUserRequest{Role: &role},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
req: updateUserRequest{Enabled: &enabled},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "library ids",
|
||||
req: updateUserRequest{LibraryIDs: updateLibraryIDsField{Set: true, Value: libraryIDs}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "max playback quality",
|
||||
req: updateUserRequest{MaxPlaybackQuality: &maxPlaybackQuality},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
req: updateUserRequest{Password: &password},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non access fields",
|
||||
req: updateUserRequest{Username: &username, MaxStreams: &maxStreams},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty update",
|
||||
req: updateUserRequest{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(tt.req); got != tt.want {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ type userResponse struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
Impersonation *impersonationResponse `json:"impersonation,omitempty"`
|
||||
}
|
||||
@@ -510,6 +511,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
Permissions: auth.EffectivePermissions(user),
|
||||
DownloadAllowed: user.DownloadAllowed,
|
||||
}
|
||||
if impersonatorUserID != nil {
|
||||
|
||||
@@ -78,7 +78,7 @@ func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
if !requestIsAdmin(r) {
|
||||
if !h.items.requestCanViewFilePaths(r) {
|
||||
for i := range detail.Versions {
|
||||
detail.Versions[i].FilePath = ""
|
||||
}
|
||||
@@ -502,7 +502,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
if !requestIsAdmin(r) {
|
||||
if !h.items.requestCanViewFilePaths(r) {
|
||||
for i := range detail.Versions {
|
||||
detail.Versions[i].FilePath = ""
|
||||
}
|
||||
|
||||
@@ -1258,7 +1258,21 @@ func isNotFound(err error) bool {
|
||||
errors.Is(err, catalog.ErrSeasonNotFound)
|
||||
}
|
||||
|
||||
func requestIsAdmin(r *http.Request) bool {
|
||||
func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
return claims != nil && claims.Role == "admin"
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.UserRepo == nil {
|
||||
return false
|
||||
}
|
||||
user, err := h.UserRepo.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "checking file path visibility permissions", "user_id", claims.UserID, "error", err)
|
||||
return false
|
||||
}
|
||||
return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -570,10 +571,10 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
} else {
|
||||
initialScanID := ulid.Make().String()
|
||||
h.recordAcceptedScan(initialScanID, &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: scanModeLibrary,
|
||||
trigger: "library_created",
|
||||
h.recordAcceptedScan(initialScanID, &scantrigger.Target{
|
||||
Folder: folder,
|
||||
Mode: scantrigger.ModeLibrary,
|
||||
Trigger: "library_created",
|
||||
})
|
||||
h.runFolderScanAsync(initialScanID, folder, "library_created")
|
||||
}
|
||||
@@ -660,10 +661,10 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
} else {
|
||||
updateScanID := ulid.Make().String()
|
||||
h.recordAcceptedScan(updateScanID, &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: scanModeLibrary,
|
||||
trigger: "library_paths_changed",
|
||||
h.recordAcceptedScan(updateScanID, &scantrigger.Target{
|
||||
Folder: folder,
|
||||
Mode: scantrigger.ModeLibrary,
|
||||
Trigger: "library_paths_changed",
|
||||
})
|
||||
h.runFolderScanAsync(updateScanID, folder, "library_paths_changed")
|
||||
}
|
||||
@@ -791,29 +792,6 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type scanMode string
|
||||
|
||||
const (
|
||||
scanModeLibrary scanMode = "library"
|
||||
scanModeSubtree scanMode = "subtree"
|
||||
scanModeFile scanMode = "file"
|
||||
)
|
||||
|
||||
type resolvedScanTarget struct {
|
||||
folder *models.MediaFolder
|
||||
mode scanMode
|
||||
path string
|
||||
trigger string
|
||||
}
|
||||
|
||||
type scanRequestError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *scanRequestError) Error() string { return e.message }
|
||||
|
||||
// HandleScan handles POST /scan. It accepts either a library_id, a path, or both
|
||||
// and dispatches to full-library, subtree, or single-file scanning.
|
||||
func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -823,11 +801,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
target, err := h.resolveScanTarget(r.Context(), req)
|
||||
target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{
|
||||
LibraryID: req.LibraryID,
|
||||
Path: req.Path,
|
||||
})
|
||||
if err != nil {
|
||||
var reqErr *scanRequestError
|
||||
var reqErr *scantrigger.RequestError
|
||||
if errors.As(err, &reqErr) {
|
||||
writeError(w, reqErr.status, reqErr.code, reqErr.message)
|
||||
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
|
||||
return
|
||||
}
|
||||
slog.Error("resolving scan target", "error", err)
|
||||
@@ -836,21 +817,21 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if h.ScanQueue != nil {
|
||||
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.folder.ID, string(target.mode), target.path, target.trigger); err != nil {
|
||||
slog.Error("queueing library scan", "library_id", target.folder.ID, "error", err)
|
||||
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil {
|
||||
slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan")
|
||||
return
|
||||
}
|
||||
} else if h.ingester != nil {
|
||||
scanID := ulid.Make().String()
|
||||
h.recordAcceptedScan(scanID, target)
|
||||
switch target.mode {
|
||||
case scanModeFile:
|
||||
h.runFileScanAsync(scanID, target.folder, target.path, target.trigger)
|
||||
case scanModeSubtree:
|
||||
h.runSubtreeScanAsync(scanID, target.folder, target.path, target.trigger)
|
||||
switch target.Mode {
|
||||
case scantrigger.ModeFile:
|
||||
h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger)
|
||||
case scantrigger.ModeSubtree:
|
||||
h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger)
|
||||
default:
|
||||
h.runFolderScanAsync(scanID, target.folder, target.trigger)
|
||||
h.runFolderScanAsync(scanID, target.Folder, target.Trigger)
|
||||
}
|
||||
} else {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
|
||||
@@ -859,8 +840,8 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusAccepted, scanResponse{
|
||||
Status: "accepted",
|
||||
Mode: string(target.mode),
|
||||
LibraryID: target.folder.ID,
|
||||
Mode: target.Mode,
|
||||
LibraryID: target.Folder.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -908,225 +889,6 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) resolveScanTarget(ctx context.Context, req scanRequest) (*resolvedScanTarget, error) {
|
||||
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Either library_id or path is required",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
folder *models.MediaFolder
|
||||
err error
|
||||
)
|
||||
if req.LibraryID != nil {
|
||||
folder, err = h.folderRepo.GetByID(ctx, *req.LibraryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrFolderNotFound) {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusNotFound,
|
||||
code: "not_found",
|
||||
message: "Library not found",
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("fetching library for scan: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusConflict,
|
||||
code: "conflict",
|
||||
message: "Library is disabled",
|
||||
}
|
||||
}
|
||||
return &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: scanModeLibrary,
|
||||
trigger: "manual",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(req.Path)
|
||||
var matchedRoot string
|
||||
if folder != nil {
|
||||
matchedRoot, err = longestMatchingRoot(cleanPath, folder.Paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if matchedRoot == "" {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path does not belong to the specified library",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
folders, err := h.folderRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing libraries for scan: %w", err)
|
||||
}
|
||||
folder, matchedRoot, err = matchFolderForPath(cleanPath, folders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &scanRequestError{
|
||||
status: http.StatusConflict,
|
||||
code: "conflict",
|
||||
message: "Library is disabled",
|
||||
}
|
||||
}
|
||||
|
||||
mode, err := classifyScanPath(cleanPath, matchedRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trigger := "path"
|
||||
if req.LibraryID != nil {
|
||||
trigger = "library_id_path"
|
||||
}
|
||||
|
||||
return &resolvedScanTarget{
|
||||
folder: folder,
|
||||
mode: mode,
|
||||
path: cleanPath,
|
||||
trigger: trigger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func longestMatchingRoot(targetPath string, roots []string) (string, error) {
|
||||
bestRoot := ""
|
||||
bestLen := -1
|
||||
for _, root := range roots {
|
||||
if !pathWithinRoot(targetPath, root) {
|
||||
continue
|
||||
}
|
||||
cleanRoot := filepath.Clean(root)
|
||||
rootLen := len(cleanRoot)
|
||||
if rootLen > bestLen {
|
||||
bestRoot = cleanRoot
|
||||
bestLen = rootLen
|
||||
}
|
||||
}
|
||||
return bestRoot, nil
|
||||
}
|
||||
|
||||
func matchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
|
||||
var (
|
||||
bestFolder *models.MediaFolder
|
||||
bestRoot string
|
||||
bestLen = -1
|
||||
ambiguous bool
|
||||
)
|
||||
|
||||
for _, folder := range folders {
|
||||
if folder == nil {
|
||||
continue
|
||||
}
|
||||
root, err := longestMatchingRoot(targetPath, folder.Paths)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
rootLen := len(root)
|
||||
if rootLen > bestLen {
|
||||
bestFolder = folder
|
||||
bestRoot = root
|
||||
bestLen = rootLen
|
||||
ambiguous = false
|
||||
continue
|
||||
}
|
||||
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
if ambiguous {
|
||||
return nil, "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path matches multiple libraries",
|
||||
}
|
||||
}
|
||||
if bestFolder == nil {
|
||||
return nil, "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "No library matches the given path",
|
||||
}
|
||||
}
|
||||
return bestFolder, bestRoot, nil
|
||||
}
|
||||
|
||||
func classifyScanPath(targetPath, matchedRoot string) (scanMode, error) {
|
||||
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
|
||||
return scanModeLibrary, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path does not exist",
|
||||
}
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Permission denied for path",
|
||||
}
|
||||
default:
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path could not be inspected",
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return scanModeSubtree, nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Path must be a file or directory",
|
||||
}
|
||||
}
|
||||
if !scanner.SupportsVideoFile(targetPath) {
|
||||
return "", &scanRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
code: "bad_request",
|
||||
message: "Unsupported media file extension",
|
||||
}
|
||||
}
|
||||
return scanModeFile, nil
|
||||
}
|
||||
|
||||
func pathWithinRoot(targetPath, rootPath string) bool {
|
||||
cleanTarget := filepath.Clean(targetPath)
|
||||
cleanRoot := filepath.Clean(rootPath)
|
||||
rel, err := filepath.Rel(cleanRoot, cleanTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if rel == "." || rel == "" {
|
||||
return true
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) {
|
||||
go func() {
|
||||
h.markScanRunning(scanID)
|
||||
@@ -1287,16 +1049,16 @@ func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFol
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) {
|
||||
if h == nil || h.ScanRegistry == nil || target == nil || target.folder == nil {
|
||||
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) {
|
||||
if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil {
|
||||
return
|
||||
}
|
||||
h.ScanRegistry.Upsert(evt.ScanRun{
|
||||
ID: scanID,
|
||||
LibraryID: target.folder.ID,
|
||||
Mode: string(target.mode),
|
||||
Path: target.path,
|
||||
Trigger: target.trigger,
|
||||
LibraryID: target.Folder.ID,
|
||||
Mode: target.Mode,
|
||||
Path: target.Path,
|
||||
Trigger: target.Trigger,
|
||||
Status: "accepted",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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/"},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type PermissionUserLoader interface {
|
||||
GetByID(ctx context.Context, id int) (*models.User, error)
|
||||
}
|
||||
|
||||
type MetadataTargetLibraryResolver interface {
|
||||
ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error)
|
||||
}
|
||||
|
||||
type PermissionMiddleware struct {
|
||||
users PermissionUserLoader
|
||||
libraries MetadataTargetLibraryResolver
|
||||
}
|
||||
|
||||
func NewPermissionMiddleware(users PermissionUserLoader, libraries MetadataTargetLibraryResolver) *PermissionMiddleware {
|
||||
return &PermissionMiddleware{users: users, libraries: libraries}
|
||||
}
|
||||
|
||||
// RequireMetadataCurationForItem allows admins or users with metadata_curation
|
||||
// permission when every library containing the target item is within the user's
|
||||
// assigned libraries. A nil user library list means unrestricted library access.
|
||||
func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
writeUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if m == nil || m.users == nil || m.libraries == nil {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
|
||||
targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID)
|
||||
if err != nil {
|
||||
writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item libraries")
|
||||
return
|
||||
}
|
||||
if len(targetLibraries) == 0 {
|
||||
writePermissionError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
if !metadataTargetWithinUserLibraries(user.LibraryIDs, targetLibraries) {
|
||||
writeForbidden(w, "Item is outside your assigned libraries")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func metadataTargetWithinUserLibraries(allowed []int, target []int) bool {
|
||||
if allowed == nil {
|
||||
return true
|
||||
}
|
||||
if len(target) == 0 {
|
||||
return false
|
||||
}
|
||||
allowedSet := make(map[int]struct{}, len(allowed))
|
||||
for _, id := range allowed {
|
||||
allowedSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range target {
|
||||
if _, ok := allowedSet[id]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type PGMetadataTargetLibraryResolver struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGMetadataTargetLibraryResolver(pool *pgxpool.Pool) *PGMetadataTargetLibraryResolver {
|
||||
return &PGMetadataTargetLibraryResolver{Pool: pool}
|
||||
}
|
||||
|
||||
func (r *PGMetadataTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) {
|
||||
if r == nil || r.Pool == nil {
|
||||
return nil, fmt.Errorf("database not configured")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
WITH target_root AS (
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
WHERE mi.content_id = $1
|
||||
UNION
|
||||
SELECT s.series_id
|
||||
FROM seasons s
|
||||
WHERE s.content_id = $1
|
||||
UNION
|
||||
SELECT e.series_id
|
||||
FROM episodes e
|
||||
WHERE e.content_id = $1
|
||||
)
|
||||
SELECT DISTINCT mil.media_folder_id
|
||||
FROM target_root tr
|
||||
JOIN media_item_libraries mil ON mil.content_id = tr.content_id
|
||||
ORDER BY mil.media_folder_id`, contentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func writePermissionError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: message})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type fakePermissionUserLoader struct {
|
||||
user *models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakePermissionUserLoader) GetByID(context.Context, int) (*models.User, error) {
|
||||
return f.user, f.err
|
||||
}
|
||||
|
||||
type fakeTargetLibraryResolver struct {
|
||||
ids []int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Context, string) ([]int, error) {
|
||||
return f.ids, f.err
|
||||
}
|
||||
|
||||
func requestWithItemID(role string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil)
|
||||
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess})
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", "item-1")
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int {
|
||||
mw := NewPermissionMiddleware(
|
||||
fakePermissionUserLoader{user: user},
|
||||
fakeTargetLibraryResolver{ids: libraryIDs},
|
||||
)
|
||||
next := mw.RequireMetadataCurationForItem(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
next.ServeHTTP(rec, requestWithItemID(role))
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
|
||||
code := runMetadataCurationMiddleware(nil, nil, "admin")
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
|
||||
code := runMetadataCurationMiddleware(user, []int{1}, "user")
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 3}, "user")
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, nil, "user")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
+342
-298
@@ -222,6 +222,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
var authHandler *handlers.AuthHandler
|
||||
var authMiddleware *apimw.AuthMiddleware
|
||||
var viewerAccessMiddleware *apimw.ViewerAccessMiddleware
|
||||
var permissionMiddleware *apimw.PermissionMiddleware
|
||||
var viewerResolver *access.Resolver
|
||||
var profileTokenService *access.ProfileTokenService
|
||||
var jwtService *auth.JWTService
|
||||
@@ -258,6 +259,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService)
|
||||
viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver)
|
||||
}
|
||||
if deps.DB != nil {
|
||||
permissionMiddleware = apimw.NewPermissionMiddleware(
|
||||
userRepo,
|
||||
apimw.NewPGMetadataTargetLibraryResolver(deps.DB),
|
||||
)
|
||||
}
|
||||
}
|
||||
if deps.SessionMgr != nil && userRepo != nil {
|
||||
deps.SessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) {
|
||||
@@ -727,6 +734,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)
|
||||
}
|
||||
@@ -734,7 +742,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)
|
||||
@@ -768,6 +776,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
|
||||
@@ -1530,9 +1542,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)
|
||||
})
|
||||
@@ -1670,324 +1691,347 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Admin routes (admin-only).
|
||||
// Admin routes.
|
||||
if adminHandler != nil {
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
r.Use(apimw.RequireAdmin)
|
||||
|
||||
r.Get("/users", adminHandler.HandleListUsers)
|
||||
r.Post("/users", adminHandler.HandleCreateUser)
|
||||
r.Get("/users/{id}", adminHandler.HandleGetUser)
|
||||
r.Put("/users/{id}", adminHandler.HandleUpdateUser)
|
||||
r.Delete("/users/{id}", adminHandler.HandleDeleteUser)
|
||||
r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser)
|
||||
r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles)
|
||||
r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings)
|
||||
r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting)
|
||||
r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting)
|
||||
r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting)
|
||||
r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings)
|
||||
r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey)
|
||||
r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting)
|
||||
r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey)
|
||||
r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting)
|
||||
r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings)
|
||||
r.Get("/devices", adminHandler.HandleListDevices)
|
||||
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
|
||||
|
||||
r.Get("/sessions", adminHandler.HandleListSessions)
|
||||
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
|
||||
r.Get("/unmatched", adminHandler.HandleListUnmatched)
|
||||
r.Get("/stats", adminHandler.HandleGetStats)
|
||||
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
|
||||
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
|
||||
if sectionSettingsHandler != nil {
|
||||
r.Get("/settings/sections", sectionSettingsHandler.HandleGet)
|
||||
r.Put("/settings/sections", sectionSettingsHandler.HandlePut)
|
||||
}
|
||||
r.Get("/settings/{key}", adminHandler.HandleGetSetting)
|
||||
r.Get("/settings", adminHandler.HandleGetSettings)
|
||||
r.Put("/settings/{key}", adminHandler.HandleUpdateSetting)
|
||||
r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata)
|
||||
r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata)
|
||||
if adminIntroHandler != nil {
|
||||
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
|
||||
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
|
||||
}
|
||||
if peopleHandler != nil {
|
||||
r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson)
|
||||
r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson)
|
||||
metadataItemAccess := apimw.RequireAdmin
|
||||
if permissionMiddleware != nil {
|
||||
metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem
|
||||
}
|
||||
|
||||
if adminMatchHandler != nil {
|
||||
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
|
||||
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
|
||||
}
|
||||
|
||||
if adminImageHandler != nil {
|
||||
r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages)
|
||||
r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage)
|
||||
}
|
||||
|
||||
filesystemHandler := handlers.NewFilesystemHandler()
|
||||
r.Get("/filesystem/browse", filesystemHandler.HandleBrowse)
|
||||
|
||||
if catalogSeedHandler != nil {
|
||||
r.Route("/catalog", func(r chi.Router) {
|
||||
r.Post("/export", catalogSeedHandler.HandleExport)
|
||||
r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob)
|
||||
r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob)
|
||||
r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob)
|
||||
r.Get("/import-sources", catalogSeedHandler.HandleListImportSources)
|
||||
r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources)
|
||||
r.Post("/import", catalogSeedHandler.HandleImport)
|
||||
})
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(metadataItemAccess)
|
||||
r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata)
|
||||
r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata)
|
||||
if adminMatchHandler != nil {
|
||||
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
|
||||
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
|
||||
}
|
||||
})
|
||||
|
||||
if adminJobsHandler != nil {
|
||||
r.Route("/jobs", func(r chi.Router) {
|
||||
r.Get("/", adminJobsHandler.HandleList)
|
||||
r.Get("/{id}", adminJobsHandler.HandleGet)
|
||||
})
|
||||
// Curators must poll their own item-refresh jobs, so this stays outside
|
||||
// the admin-only group. HandleGet enforces per-job authorization.
|
||||
r.Get("/jobs/{id}", adminJobsHandler.HandleGet)
|
||||
}
|
||||
|
||||
if deps.PluginService != nil && deps.PluginUserConfig != nil {
|
||||
pluginHandler := handlers.NewPluginHandler(
|
||||
plugins.NewRepositoryStore(deps.DB),
|
||||
plugins.NewInstallationStore(deps.DB),
|
||||
plugins.NewRuntimeConfigStore(deps.DB),
|
||||
deps.PluginService,
|
||||
deps.PluginUserConfig,
|
||||
deps.PluginHTTPProxy,
|
||||
metadata.NewChainRepository(deps.DB),
|
||||
deps.PluginImageResolver,
|
||||
)
|
||||
r.Route("/plugins", func(r chi.Router) {
|
||||
r.Get("/repositories", pluginHandler.HandleListRepositories)
|
||||
r.Post("/repositories", pluginHandler.HandleCreateRepository)
|
||||
r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository)
|
||||
r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository)
|
||||
r.Get("/catalog", pluginHandler.HandleCatalog)
|
||||
r.Get("/installations", pluginHandler.HandleListInstallations)
|
||||
r.Post("/installations", pluginHandler.HandleCreateInstallation)
|
||||
r.Post("/uploads", pluginHandler.HandleUploadInstallation)
|
||||
r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation)
|
||||
r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate)
|
||||
r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig)
|
||||
r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig)
|
||||
r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding)
|
||||
r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding)
|
||||
r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation)
|
||||
})
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.RequireAdmin)
|
||||
|
||||
if historyImportHandler != nil {
|
||||
r.Route("/history-import-sources", func(r chi.Router) {
|
||||
r.Get("/", historyImportHandler.HandleAdminListSources)
|
||||
r.Post("/", historyImportHandler.HandleAdminCreateSource)
|
||||
r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource)
|
||||
r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource)
|
||||
})
|
||||
r.Get("/users", adminHandler.HandleListUsers)
|
||||
r.Post("/users", adminHandler.HandleCreateUser)
|
||||
r.Get("/users/{id}", adminHandler.HandleGetUser)
|
||||
r.Put("/users/{id}", adminHandler.HandleUpdateUser)
|
||||
r.Delete("/users/{id}", adminHandler.HandleDeleteUser)
|
||||
r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser)
|
||||
r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles)
|
||||
r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings)
|
||||
r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting)
|
||||
r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting)
|
||||
r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting)
|
||||
r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings)
|
||||
r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey)
|
||||
r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting)
|
||||
r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey)
|
||||
r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting)
|
||||
r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings)
|
||||
r.Get("/devices", adminHandler.HandleListDevices)
|
||||
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
|
||||
|
||||
r.Route("/history-imports", func(r chi.Router) {
|
||||
r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin)
|
||||
r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken)
|
||||
r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken)
|
||||
r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers)
|
||||
r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun)
|
||||
r.Get("/mappings", historyImportHandler.HandleAdminListMappings)
|
||||
r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping)
|
||||
r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping)
|
||||
r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping)
|
||||
r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun)
|
||||
r.Get("/runs", historyImportHandler.HandleAdminListRuns)
|
||||
r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun)
|
||||
r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun)
|
||||
})
|
||||
}
|
||||
|
||||
if sectionHandler != nil {
|
||||
r.Route("/sections", func(r chi.Router) {
|
||||
r.Get("/", sectionHandler.HandleListSections)
|
||||
r.Post("/", sectionHandler.HandleCreateSection)
|
||||
r.Post("/preview", sectionHandler.HandlePreview)
|
||||
r.Put("/reorder", sectionHandler.HandleReorderSections)
|
||||
r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults)
|
||||
r.Put("/{id}", sectionHandler.HandleUpdateSection)
|
||||
r.Delete("/{id}", sectionHandler.HandleDeleteSection)
|
||||
if sectionBulkHandler != nil {
|
||||
r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if libraryCollectionHandler != nil {
|
||||
collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil)
|
||||
r.Route("/collections", func(r chi.Router) {
|
||||
r.Get("/", libraryCollectionHandler.HandleListAdminCollections)
|
||||
r.Get("/templates", collectionTemplateHandler.HandleListTemplates)
|
||||
r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles)
|
||||
r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle)
|
||||
r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob)
|
||||
r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection)
|
||||
r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection)
|
||||
r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections)
|
||||
r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection)
|
||||
r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection)
|
||||
r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection)
|
||||
r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage)
|
||||
r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems)
|
||||
r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem)
|
||||
r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem)
|
||||
r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList)
|
||||
r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection)
|
||||
r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection)
|
||||
})
|
||||
}
|
||||
if libraryCollectionGroupHandler != nil {
|
||||
r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) {
|
||||
r.Get("/", libraryCollectionGroupHandler.HandleListGroups)
|
||||
r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup)
|
||||
r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups)
|
||||
})
|
||||
r.Route("/collection-groups", func(r chi.Router) {
|
||||
r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup)
|
||||
r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup)
|
||||
r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup)
|
||||
})
|
||||
}
|
||||
|
||||
if deps.NodeRepo != nil {
|
||||
jwtSecret := ""
|
||||
if deps.Config != nil {
|
||||
jwtSecret = deps.Config.Auth.JWTSecret
|
||||
r.Get("/sessions", adminHandler.HandleListSessions)
|
||||
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
|
||||
r.Get("/unmatched", adminHandler.HandleListUnmatched)
|
||||
r.Get("/stats", adminHandler.HandleGetStats)
|
||||
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
|
||||
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
|
||||
if sectionSettingsHandler != nil {
|
||||
r.Get("/settings/sections", sectionSettingsHandler.HandleGet)
|
||||
r.Put("/settings/sections", sectionSettingsHandler.HandlePut)
|
||||
}
|
||||
nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret)
|
||||
r.Route("/nodes", func(r chi.Router) {
|
||||
r.Get("/", nodeHandler.HandleListNodes)
|
||||
r.Post("/", nodeHandler.HandleCreateNode)
|
||||
r.Put("/{id}", nodeHandler.HandleUpdateNode)
|
||||
r.Delete("/{id}", nodeHandler.HandleDeleteNode)
|
||||
r.Post("/{id}/check", nodeHandler.HandleCheckNode)
|
||||
r.Post("/force-reload", nodeHandler.HandleForceReloadNodes)
|
||||
r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode)
|
||||
})
|
||||
// Live node sessions (reads from Redis)
|
||||
// Note: /admin/sessions is already used for playback sessions from PostgreSQL.
|
||||
r.Get("/node-sessions", nodeHandler.HandleListSessions)
|
||||
}
|
||||
|
||||
// System inspection.
|
||||
{
|
||||
sysJWTSecret := ""
|
||||
if deps.Config != nil {
|
||||
sysJWTSecret = deps.Config.Auth.JWTSecret
|
||||
r.Get("/settings/{key}", adminHandler.HandleGetSetting)
|
||||
r.Get("/settings", adminHandler.HandleGetSettings)
|
||||
r.Put("/settings/{key}", adminHandler.HandleUpdateSetting)
|
||||
if adminIntroHandler != nil {
|
||||
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
|
||||
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
|
||||
}
|
||||
if peopleHandler != nil {
|
||||
r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson)
|
||||
r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson)
|
||||
}
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
|
||||
r.Route("/system", func(r chi.Router) {
|
||||
r.Get("/build", systemHandler.HandleBuildInfo)
|
||||
r.Get("/hw-accel", systemHandler.HandleHWAccel)
|
||||
})
|
||||
}
|
||||
|
||||
if deps.RecWorker != nil {
|
||||
adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker)
|
||||
r.Route("/recommendations", func(r chi.Router) {
|
||||
r.Get("/status", adminRecsHandler.HandleStatus)
|
||||
r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings)
|
||||
r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles)
|
||||
r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch)
|
||||
r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations)
|
||||
})
|
||||
}
|
||||
if adminImageHandler != nil {
|
||||
r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages)
|
||||
r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage)
|
||||
}
|
||||
|
||||
if inviteCodeRepo != nil {
|
||||
inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo)
|
||||
r.Route("/invite-codes", func(r chi.Router) {
|
||||
r.Get("/", inviteCodeHandler.HandleListInviteCodes)
|
||||
r.Post("/", inviteCodeHandler.HandleCreateInviteCode)
|
||||
r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode)
|
||||
r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode)
|
||||
r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode)
|
||||
})
|
||||
}
|
||||
filesystemHandler := handlers.NewFilesystemHandler()
|
||||
r.Get("/filesystem/browse", filesystemHandler.HandleBrowse)
|
||||
|
||||
if adminSubtitleHandler != nil {
|
||||
r.Route("/subtitle-providers", func(r chi.Router) {
|
||||
r.Get("/", adminSubtitleHandler.HandleListProviders)
|
||||
r.Route("/{provider}", func(r chi.Router) {
|
||||
r.Put("/", adminSubtitleHandler.HandleUpdateProvider)
|
||||
r.Post("/test", adminSubtitleHandler.HandleTestProvider)
|
||||
if catalogSeedHandler != nil {
|
||||
r.Route("/catalog", func(r chi.Router) {
|
||||
r.Post("/export", catalogSeedHandler.HandleExport)
|
||||
r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob)
|
||||
r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob)
|
||||
r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob)
|
||||
r.Get("/import-sources", catalogSeedHandler.HandleListImportSources)
|
||||
r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources)
|
||||
r.Post("/import", catalogSeedHandler.HandleImport)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit admin routes
|
||||
if deps.RateLimitMW != nil && settingsRepo != nil {
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
|
||||
r.Route("/rate-limits", func(r chi.Router) {
|
||||
r.Get("/config", rateLimitHandler.HandleGetConfig)
|
||||
r.Put("/config", rateLimitHandler.HandleUpdateConfig)
|
||||
})
|
||||
}
|
||||
if adminJobsHandler != nil {
|
||||
r.Route("/jobs", func(r chi.Router) {
|
||||
r.Get("/", adminJobsHandler.HandleList)
|
||||
})
|
||||
}
|
||||
|
||||
if apiKeyRepo != nil {
|
||||
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo)
|
||||
r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys)
|
||||
r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys)
|
||||
r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey)
|
||||
r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey)
|
||||
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
|
||||
}
|
||||
if deps.PluginService != nil && deps.PluginUserConfig != nil {
|
||||
pluginHandler := handlers.NewPluginHandler(
|
||||
plugins.NewRepositoryStore(deps.DB),
|
||||
plugins.NewInstallationStore(deps.DB),
|
||||
plugins.NewRuntimeConfigStore(deps.DB),
|
||||
deps.PluginService,
|
||||
deps.PluginUserConfig,
|
||||
deps.PluginHTTPProxy,
|
||||
metadata.NewChainRepository(deps.DB),
|
||||
deps.PluginImageResolver,
|
||||
)
|
||||
r.Route("/plugins", func(r chi.Router) {
|
||||
r.Get("/repositories", pluginHandler.HandleListRepositories)
|
||||
r.Post("/repositories", pluginHandler.HandleCreateRepository)
|
||||
r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository)
|
||||
r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository)
|
||||
r.Get("/catalog", pluginHandler.HandleCatalog)
|
||||
r.Get("/installations", pluginHandler.HandleListInstallations)
|
||||
r.Post("/installations", pluginHandler.HandleCreateInstallation)
|
||||
r.Post("/uploads", pluginHandler.HandleUploadInstallation)
|
||||
r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation)
|
||||
r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate)
|
||||
r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig)
|
||||
r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig)
|
||||
r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding)
|
||||
r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding)
|
||||
r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation)
|
||||
})
|
||||
}
|
||||
|
||||
if requestHandler != nil {
|
||||
r.Get("/requests", requestHandler.HandleAdminList)
|
||||
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
|
||||
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
|
||||
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
|
||||
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
|
||||
r.Get("/request-settings", requestHandler.HandleGetSettings)
|
||||
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
|
||||
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
|
||||
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
|
||||
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
|
||||
r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
|
||||
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
|
||||
}
|
||||
if historyImportHandler != nil {
|
||||
r.Route("/history-import-sources", func(r chi.Router) {
|
||||
r.Get("/", historyImportHandler.HandleAdminListSources)
|
||||
r.Post("/", historyImportHandler.HandleAdminCreateSource)
|
||||
r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource)
|
||||
r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource)
|
||||
})
|
||||
|
||||
if deps.ActivityLogRepo != nil {
|
||||
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
|
||||
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
|
||||
r.Get("/ips", adminIPHandler.HandleGetIPUsers)
|
||||
}
|
||||
if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil {
|
||||
adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub)
|
||||
r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs)
|
||||
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
|
||||
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
|
||||
}
|
||||
if adminPlaybackControlHandler != nil {
|
||||
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
|
||||
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
|
||||
r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession)
|
||||
r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession)
|
||||
r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession)
|
||||
}
|
||||
r.Route("/history-imports", func(r chi.Router) {
|
||||
r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin)
|
||||
r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken)
|
||||
r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken)
|
||||
r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers)
|
||||
r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun)
|
||||
r.Get("/mappings", historyImportHandler.HandleAdminListMappings)
|
||||
r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping)
|
||||
r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping)
|
||||
r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping)
|
||||
r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun)
|
||||
r.Get("/runs", historyImportHandler.HandleAdminListRuns)
|
||||
r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun)
|
||||
r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun)
|
||||
})
|
||||
}
|
||||
|
||||
if deps.TaskManager != nil {
|
||||
taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB)
|
||||
taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB))
|
||||
taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics)
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", taskHandler.HandleListTasks)
|
||||
r.Get("/{key}", taskHandler.HandleGetTask)
|
||||
r.Get("/{key}/metrics", taskHandler.HandleGetMetrics)
|
||||
r.Post("/{key}/run", taskHandler.HandleRunTask)
|
||||
r.Post("/{key}/cancel", taskHandler.HandleCancelTask)
|
||||
r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers)
|
||||
r.Get("/{key}/history", taskHandler.HandleGetHistory)
|
||||
})
|
||||
}
|
||||
if sectionHandler != nil {
|
||||
r.Route("/sections", func(r chi.Router) {
|
||||
r.Get("/", sectionHandler.HandleListSections)
|
||||
r.Post("/", sectionHandler.HandleCreateSection)
|
||||
r.Post("/preview", sectionHandler.HandlePreview)
|
||||
r.Put("/reorder", sectionHandler.HandleReorderSections)
|
||||
r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults)
|
||||
r.Put("/{id}", sectionHandler.HandleUpdateSection)
|
||||
r.Delete("/{id}", sectionHandler.HandleDeleteSection)
|
||||
if sectionBulkHandler != nil {
|
||||
r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if libraryCollectionHandler != nil {
|
||||
collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil)
|
||||
r.Route("/collections", func(r chi.Router) {
|
||||
r.Get("/", libraryCollectionHandler.HandleListAdminCollections)
|
||||
r.Get("/templates", collectionTemplateHandler.HandleListTemplates)
|
||||
r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles)
|
||||
r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle)
|
||||
r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob)
|
||||
r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection)
|
||||
r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection)
|
||||
r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections)
|
||||
r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection)
|
||||
r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection)
|
||||
r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection)
|
||||
r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage)
|
||||
r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems)
|
||||
r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem)
|
||||
r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem)
|
||||
r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList)
|
||||
r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection)
|
||||
r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection)
|
||||
})
|
||||
}
|
||||
if libraryCollectionGroupHandler != nil {
|
||||
r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) {
|
||||
r.Get("/", libraryCollectionGroupHandler.HandleListGroups)
|
||||
r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup)
|
||||
r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups)
|
||||
})
|
||||
r.Route("/collection-groups", func(r chi.Router) {
|
||||
r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup)
|
||||
r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup)
|
||||
r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup)
|
||||
})
|
||||
}
|
||||
|
||||
if deps.NodeRepo != nil {
|
||||
jwtSecret := ""
|
||||
if deps.Config != nil {
|
||||
jwtSecret = deps.Config.Auth.JWTSecret
|
||||
}
|
||||
nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret)
|
||||
r.Route("/nodes", func(r chi.Router) {
|
||||
r.Get("/", nodeHandler.HandleListNodes)
|
||||
r.Post("/", nodeHandler.HandleCreateNode)
|
||||
r.Put("/{id}", nodeHandler.HandleUpdateNode)
|
||||
r.Delete("/{id}", nodeHandler.HandleDeleteNode)
|
||||
r.Post("/{id}/check", nodeHandler.HandleCheckNode)
|
||||
r.Post("/force-reload", nodeHandler.HandleForceReloadNodes)
|
||||
r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode)
|
||||
})
|
||||
// Live node sessions (reads from Redis)
|
||||
// Note: /admin/sessions is already used for playback sessions from PostgreSQL.
|
||||
r.Get("/node-sessions", nodeHandler.HandleListSessions)
|
||||
}
|
||||
|
||||
// System inspection.
|
||||
{
|
||||
sysJWTSecret := ""
|
||||
if deps.Config != nil {
|
||||
sysJWTSecret = deps.Config.Auth.JWTSecret
|
||||
}
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
|
||||
r.Route("/system", func(r chi.Router) {
|
||||
r.Get("/build", systemHandler.HandleBuildInfo)
|
||||
r.Get("/hw-accel", systemHandler.HandleHWAccel)
|
||||
})
|
||||
}
|
||||
|
||||
if deps.RecWorker != nil {
|
||||
adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker)
|
||||
r.Route("/recommendations", func(r chi.Router) {
|
||||
r.Get("/status", adminRecsHandler.HandleStatus)
|
||||
r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings)
|
||||
r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles)
|
||||
r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch)
|
||||
r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations)
|
||||
})
|
||||
}
|
||||
|
||||
if inviteCodeRepo != nil {
|
||||
inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo)
|
||||
r.Route("/invite-codes", func(r chi.Router) {
|
||||
r.Get("/", inviteCodeHandler.HandleListInviteCodes)
|
||||
r.Post("/", inviteCodeHandler.HandleCreateInviteCode)
|
||||
r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode)
|
||||
r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode)
|
||||
r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode)
|
||||
})
|
||||
}
|
||||
|
||||
if adminSubtitleHandler != nil {
|
||||
r.Route("/subtitle-providers", func(r chi.Router) {
|
||||
r.Get("/", adminSubtitleHandler.HandleListProviders)
|
||||
r.Route("/{provider}", func(r chi.Router) {
|
||||
r.Put("/", adminSubtitleHandler.HandleUpdateProvider)
|
||||
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
|
||||
if deps.RateLimitMW != nil && settingsRepo != nil {
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
|
||||
r.Route("/rate-limits", func(r chi.Router) {
|
||||
r.Get("/config", rateLimitHandler.HandleGetConfig)
|
||||
r.Put("/config", rateLimitHandler.HandleUpdateConfig)
|
||||
})
|
||||
}
|
||||
|
||||
if apiKeyRepo != nil {
|
||||
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo)
|
||||
r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys)
|
||||
r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys)
|
||||
r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey)
|
||||
r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey)
|
||||
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
|
||||
}
|
||||
|
||||
if requestHandler != nil {
|
||||
r.Get("/requests", requestHandler.HandleAdminList)
|
||||
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
|
||||
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
|
||||
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
|
||||
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
|
||||
r.Get("/request-settings", requestHandler.HandleGetSettings)
|
||||
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
|
||||
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
|
||||
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
|
||||
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
|
||||
r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
|
||||
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
|
||||
}
|
||||
|
||||
if deps.ActivityLogRepo != nil {
|
||||
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
|
||||
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
|
||||
r.Get("/ips", adminIPHandler.HandleGetIPUsers)
|
||||
}
|
||||
if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil {
|
||||
adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub)
|
||||
r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs)
|
||||
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
|
||||
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
|
||||
}
|
||||
if adminPlaybackControlHandler != nil {
|
||||
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
|
||||
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
|
||||
r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession)
|
||||
r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession)
|
||||
r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession)
|
||||
}
|
||||
|
||||
if deps.TaskManager != nil {
|
||||
taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB)
|
||||
taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB))
|
||||
taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics)
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Get("/", taskHandler.HandleListTasks)
|
||||
r.Get("/{key}", taskHandler.HandleGetTask)
|
||||
r.Get("/{key}/metrics", taskHandler.HandleGetMetrics)
|
||||
r.Post("/{key}/run", taskHandler.HandleRunTask)
|
||||
r.Post("/{key}/cancel", taskHandler.HandleCancelTask)
|
||||
r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers)
|
||||
r.Get("/{key}/history", taskHandler.HandleGetHistory)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type Permission string
|
||||
|
||||
const PermissionMetadataCuration Permission = "metadata_curation"
|
||||
|
||||
var assignablePermissions = map[Permission]struct{}{
|
||||
PermissionMetadataCuration: {},
|
||||
}
|
||||
|
||||
func assignablePermissionList() []string {
|
||||
out := make([]string, 0, len(assignablePermissions))
|
||||
for permission := range assignablePermissions {
|
||||
out = append(out, string(permission))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func isAssignablePermission(permission Permission) bool {
|
||||
_, ok := assignablePermissions[permission]
|
||||
return ok
|
||||
}
|
||||
|
||||
func NormalizePermissions(values []string) ([]string, error) {
|
||||
if len(values) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
permission := Permission(key)
|
||||
if !isAssignablePermission(permission) {
|
||||
return nil, fmt.Errorf("unknown permission %q", key)
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func HasAssignedPermission(user *models.User, permission Permission) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
for _, value := range user.Permissions {
|
||||
if value == string(permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HasEffectivePermission(user *models.User, permission Permission) bool {
|
||||
if user == nil || !user.Enabled {
|
||||
return false
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return isAssignablePermission(permission)
|
||||
}
|
||||
return HasAssignedPermission(user, permission)
|
||||
}
|
||||
|
||||
func EffectivePermissions(user *models.User) []string {
|
||||
if user == nil || !user.Enabled {
|
||||
return []string{}
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return assignablePermissionList()
|
||||
}
|
||||
permissions, err := NormalizePermissions(user.Permissions)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestNormalizePermissions_DeduplicatesAndSorts(t *testing.T) {
|
||||
got, err := NormalizePermissions([]string{
|
||||
" metadata_curation ",
|
||||
"metadata_curation",
|
||||
"",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePermissions returned error: %v", err)
|
||||
}
|
||||
want := []string{"metadata_curation"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("permissions = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) {
|
||||
if _, err := NormalizePermissions([]string{"server_owner"}); err == nil {
|
||||
t.Fatal("expected unknown permission error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasEffectivePermission_AdminImpliesMetadataCuration(t *testing.T) {
|
||||
user := &models.User{Role: "admin", Enabled: true}
|
||||
if !HasEffectivePermission(user, PermissionMetadataCuration) {
|
||||
t.Fatal("admin should have metadata curation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
|
||||
user := &models.User{Role: "user", Enabled: true}
|
||||
if HasEffectivePermission(user, PermissionMetadataCuration) {
|
||||
t.Fatal("plain user should not have metadata curation")
|
||||
}
|
||||
user.Permissions = []string{"metadata_curation"}
|
||||
if !HasEffectivePermission(user, PermissionMetadataCuration) {
|
||||
t.Fatal("assigned user should have metadata curation")
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
|
||||
|
||||
// allColumns is the list of columns returned by all SELECT queries.
|
||||
// Kept in one place so scanUser stays in sync.
|
||||
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, enabled,
|
||||
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled,
|
||||
library_ids, max_playback_quality, access_policy_revision,
|
||||
max_streams, max_transcodes, max_profiles, download_allowed,
|
||||
download_transcode_allowed, created_at, updated_at`
|
||||
@@ -64,6 +64,7 @@ func scanUser(row pgx.Row) (*models.User, error) {
|
||||
&u.PasswordHash,
|
||||
&u.LocalPasswordLoginEnabled,
|
||||
&u.Role,
|
||||
&u.Permissions,
|
||||
&u.Enabled,
|
||||
&u.LibraryIDs,
|
||||
&u.MaxPlaybackQuality,
|
||||
@@ -97,6 +98,7 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
|
||||
&u.PasswordHash,
|
||||
&u.LocalPasswordLoginEnabled,
|
||||
&u.Role,
|
||||
&u.Permissions,
|
||||
&u.Enabled,
|
||||
&u.LibraryIDs,
|
||||
&u.MaxPlaybackQuality,
|
||||
@@ -133,13 +135,19 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
|
||||
localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled
|
||||
}
|
||||
|
||||
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "library_ids", "max_playback_quality"}
|
||||
permissions, err := NormalizePermissions(input.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"}
|
||||
args := []any{
|
||||
input.Email,
|
||||
input.Username,
|
||||
string(hash),
|
||||
localPasswordLoginEnabled,
|
||||
input.Role,
|
||||
permissions,
|
||||
input.LibraryIDs,
|
||||
input.MaxPlaybackQuality,
|
||||
}
|
||||
@@ -245,6 +253,15 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
|
||||
args = append(args, *input.Role)
|
||||
argIndex++
|
||||
}
|
||||
if input.Permissions != nil {
|
||||
permissions, err := NormalizePermissions(*input.Permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex))
|
||||
args = append(args, permissions)
|
||||
argIndex++
|
||||
}
|
||||
if input.Enabled != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex))
|
||||
args = append(args, *input.Enabled)
|
||||
@@ -292,6 +309,14 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
|
||||
return err
|
||||
}
|
||||
|
||||
if input.Role != nil ||
|
||||
input.Enabled != nil ||
|
||||
input.LibraryIDs != nil ||
|
||||
input.MaxPlaybackQuality != nil ||
|
||||
input.Permissions != nil {
|
||||
setClauses = append(setClauses, "access_policy_revision = access_policy_revision + 1")
|
||||
}
|
||||
|
||||
// Always bump updated_at.
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
|
||||
@@ -51,9 +51,24 @@ func FileAllowedByAccess(file *models.MediaFile, filter AccessFilter) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
if filter.AllowedLibraryIDs != nil && !intInSlice(file.MediaFolderID, filter.AllowedLibraryIDs) {
|
||||
return false
|
||||
}
|
||||
if len(filter.DisabledLibraryIDs) > 0 && intInSlice(file.MediaFolderID, filter.DisabledLibraryIDs) {
|
||||
return false
|
||||
}
|
||||
return access.QualityAllowed(file.Resolution, filter.MaxPlaybackQuality)
|
||||
}
|
||||
|
||||
func intInSlice(value int, values []int) bool {
|
||||
for _, candidate := range values {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterMediaFilesByAccess drops file versions that exceed the viewer's
|
||||
// effective quality ceiling.
|
||||
func FilterMediaFilesByAccess(files []*models.MediaFile, filter AccessFilter) []*models.MediaFile {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
role TEXT,
|
||||
permissions TEXT[] DEFAULT '{}'::TEXT[] NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type adminAPIKeyContextKey string
|
||||
|
||||
const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key"
|
||||
|
||||
type apiKeyValidator interface {
|
||||
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
|
||||
UpdateLastUsed(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type apiKeyUserLoader interface {
|
||||
GetByID(ctx context.Context, id int) (*models.User, error)
|
||||
}
|
||||
|
||||
type AdminAPIKeyAuthenticator struct {
|
||||
keys apiKeyValidator
|
||||
users apiKeyUserLoader
|
||||
}
|
||||
|
||||
type adminAPIKeyAuthResult struct {
|
||||
ctx context.Context
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
ok bool
|
||||
}
|
||||
|
||||
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
|
||||
if keys == nil || users == nil {
|
||||
return nil
|
||||
}
|
||||
return &AdminAPIKeyAuthenticator{keys: keys, users: users}
|
||||
}
|
||||
|
||||
func AdminAPIKeyFromContext(ctx context.Context) bool {
|
||||
ok, _ := ctx.Value(adminAPIKeyKey).(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
result := a.authenticate(r)
|
||||
if !result.ok {
|
||||
writeError(w, result.status, result.code, result.message)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(result.ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := ExtractToken(r)
|
||||
if ok && strings.HasPrefix(token, "sa_") {
|
||||
result := keyAuth.authenticate(r)
|
||||
if !result.ok {
|
||||
writeError(w, result.status, result.code, result.message)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(result.ctx))
|
||||
return
|
||||
}
|
||||
sessionAuth.RequireSession(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult {
|
||||
unauthorized := adminAPIKeyAuthResult{
|
||||
ctx: r.Context(),
|
||||
status: http.StatusUnauthorized,
|
||||
code: "Unauthorized",
|
||||
message: "Invalid API key",
|
||||
}
|
||||
if a == nil || a.keys == nil || a.users == nil {
|
||||
return unauthorized
|
||||
}
|
||||
token, ok := ExtractToken(r)
|
||||
if !ok || !strings.HasPrefix(token, "sa_") {
|
||||
return unauthorized
|
||||
}
|
||||
apiKey, err := a.keys.GetByKey(r.Context(), token)
|
||||
if err != nil || apiKey == nil {
|
||||
return unauthorized
|
||||
}
|
||||
user, err := a.users.GetByID(r.Context(), apiKey.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
return unauthorized
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
return adminAPIKeyAuthResult{
|
||||
ctx: r.Context(),
|
||||
status: http.StatusForbidden,
|
||||
code: "Forbidden",
|
||||
message: "Admin access required",
|
||||
}
|
||||
}
|
||||
go func(id int64) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := a.keys.UpdateLastUsed(ctx, id); err != nil {
|
||||
slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err)
|
||||
}
|
||||
}(apiKey.ID)
|
||||
return adminAPIKeyAuthResult{
|
||||
ctx: context.WithValue(r.Context(), adminAPIKeyKey, true),
|
||||
status: http.StatusOK,
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
|
||||
@@ -114,3 +118,128 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
|
||||
t.Errorf("expected 200 (no authService = skip refresh), got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
|
||||
authn := NewAdminAPIKeyAuthenticator(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !AdminAPIKeyFromContext(r.Context()) {
|
||||
t.Fatal("expected admin API key marker in context")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
|
||||
authn := NewAdminAPIKeyAuthenticator(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not run")
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
|
||||
authn := NewAdminAPIKeyAuthenticator(
|
||||
&fakeAPIKeyValidator{returnNilWithoutError: true},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not run")
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
|
||||
called := make(chan bool, 1)
|
||||
authn := NewAdminAPIKeyAuthenticator(
|
||||
&fakeAPIKeyValidator{
|
||||
key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"},
|
||||
update: func(ctx context.Context, _ int64) error {
|
||||
_, ok := ctx.Deadline()
|
||||
called <- ok
|
||||
return nil
|
||||
},
|
||||
},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
select {
|
||||
case ok := <-called:
|
||||
if !ok {
|
||||
t.Fatal("expected last-used update context to have a deadline")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for last-used update")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAPIKeyValidator struct {
|
||||
key *models.APIKey
|
||||
returnNilWithoutError bool
|
||||
update func(context.Context, int64) error
|
||||
}
|
||||
|
||||
func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) {
|
||||
if f.returnNilWithoutError {
|
||||
return nil, nil
|
||||
}
|
||||
if f.key != nil && f.key.Key == key {
|
||||
return f.key, nil
|
||||
}
|
||||
return nil, auth.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
func (f *fakeAPIKeyValidator) UpdateLastUsed(ctx context.Context, id int64) error {
|
||||
if f.update != nil {
|
||||
return f.update(ctx, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAPIKeyUserLoader struct {
|
||||
user *models.User
|
||||
}
|
||||
|
||||
func (f *fakeAPIKeyUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
|
||||
if f.user != nil && f.user.ID == id {
|
||||
return f.user, nil
|
||||
}
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ import (
|
||||
)
|
||||
|
||||
type compatEpisodeTarget struct {
|
||||
Item upstreamListItem
|
||||
SeriesPosterURL string
|
||||
SeriesBackdropURL string
|
||||
Item upstreamListItem
|
||||
SeriesImages seriesImageSet
|
||||
}
|
||||
|
||||
type libraryMembershipChecker interface {
|
||||
@@ -204,6 +203,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
e.rating_tmdb,
|
||||
e.air_date,
|
||||
e.still_path,
|
||||
COALESCE(e.still_thumbhash, ''),
|
||||
e.updated_at,
|
||||
e.season_number,
|
||||
e.episode_number,
|
||||
si.content_id,
|
||||
@@ -211,9 +212,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
si.genres,
|
||||
si.content_rating,
|
||||
si.poster_path,
|
||||
COALESCE(si.poster_thumbhash, ''),
|
||||
si.backdrop_path,
|
||||
COALESCE(si.backdrop_thumbhash, ''),
|
||||
si.logo_path,
|
||||
si.status
|
||||
si.status,
|
||||
si.updated_at
|
||||
FROM %s
|
||||
WHERE %s
|
||||
ORDER BY e.content_id
|
||||
@@ -236,6 +240,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
ratingTMDB *float64
|
||||
airDate *time.Time
|
||||
stillPath string
|
||||
stillThumbhash string
|
||||
updatedAt time.Time
|
||||
seasonNumber int
|
||||
episodeNumber int
|
||||
seriesID string
|
||||
@@ -243,9 +249,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
genres []string
|
||||
contentRating string
|
||||
seriesPosterPath string
|
||||
seriesPosterTH string
|
||||
seriesBackdrop string
|
||||
seriesBackdropTH string
|
||||
seriesLogoPath string
|
||||
status string
|
||||
seriesUpdatedAt time.Time
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&contentID,
|
||||
@@ -256,6 +265,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
&ratingTMDB,
|
||||
&airDate,
|
||||
&stillPath,
|
||||
&stillThumbhash,
|
||||
&updatedAt,
|
||||
&seasonNumber,
|
||||
&episodeNumber,
|
||||
&seriesID,
|
||||
@@ -263,45 +274,59 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
&genres,
|
||||
&contentRating,
|
||||
&seriesPosterPath,
|
||||
&seriesPosterTH,
|
||||
&seriesBackdrop,
|
||||
&seriesBackdropTH,
|
||||
&seriesLogoPath,
|
||||
&status,
|
||||
&seriesUpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning compat episode target: %w", err)
|
||||
}
|
||||
|
||||
listItem := upstreamListItem{
|
||||
ContentID: contentID,
|
||||
Type: "episode",
|
||||
Title: title,
|
||||
Genres: genres,
|
||||
ContentRating: contentRating,
|
||||
Status: status,
|
||||
RatingIMDB: ratingIMDB,
|
||||
RatingTMDB: ratingTMDB,
|
||||
Overview: overview,
|
||||
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
|
||||
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
|
||||
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
|
||||
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
|
||||
PosterPath: stillPath,
|
||||
BackdropPath: seriesBackdrop,
|
||||
LogoPath: seriesLogoPath,
|
||||
StillPath: stillPath,
|
||||
SeriesID: seriesID,
|
||||
SeriesTitle: seriesTitle,
|
||||
SeasonNumber: intPtr(seasonNumber),
|
||||
EpisodeNumber: intPtr(episodeNumber),
|
||||
Runtime: runtime,
|
||||
ContentID: contentID,
|
||||
Type: "episode",
|
||||
Title: title,
|
||||
Genres: genres,
|
||||
ContentRating: contentRating,
|
||||
Status: status,
|
||||
RatingIMDB: ratingIMDB,
|
||||
RatingTMDB: ratingTMDB,
|
||||
Overview: overview,
|
||||
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
|
||||
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
|
||||
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
|
||||
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
|
||||
PosterPath: stillPath,
|
||||
BackdropPath: seriesBackdrop,
|
||||
BackdropThumbhash: seriesBackdropTH,
|
||||
LogoPath: seriesLogoPath,
|
||||
StillPath: stillPath,
|
||||
StillThumbhash: stillThumbhash,
|
||||
UpdatedAt: updatedAt,
|
||||
SeriesID: seriesID,
|
||||
SeriesTitle: seriesTitle,
|
||||
SeasonNumber: intPtr(seasonNumber),
|
||||
EpisodeNumber: intPtr(episodeNumber),
|
||||
Runtime: runtime,
|
||||
}
|
||||
if airDate != nil {
|
||||
listItem.AirDate = airDate.Format(time.DateOnly)
|
||||
}
|
||||
|
||||
result[contentID] = compatEpisodeTarget{
|
||||
Item: listItem,
|
||||
SeriesPosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"),
|
||||
SeriesBackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
|
||||
Item: listItem,
|
||||
SeriesImages: seriesImageSet{
|
||||
ContentID: seriesID,
|
||||
PosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"),
|
||||
PosterPath: seriesPosterPath,
|
||||
PosterThumbhash: seriesPosterTH,
|
||||
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
|
||||
BackdropPath: seriesBackdrop,
|
||||
BackdropThumbhash: seriesBackdropTH,
|
||||
UpdatedAt: seriesUpdatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -385,6 +410,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
|
||||
BackdropThumbhash: series.BackdropThumbhash,
|
||||
LogoPath: series.LogoPath,
|
||||
StillPath: episode.StillPath,
|
||||
StillThumbhash: episode.StillThumbhash,
|
||||
UpdatedAt: episode.UpdatedAt,
|
||||
SeriesID: episode.SeriesID,
|
||||
SeriesTitle: series.Title,
|
||||
@@ -396,9 +422,17 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
|
||||
listItem.AirDate = episode.AirDate.Format(time.DateOnly)
|
||||
}
|
||||
result[episode.ContentID] = compatEpisodeTarget{
|
||||
Item: listItem,
|
||||
SeriesPosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"),
|
||||
SeriesBackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
|
||||
Item: listItem,
|
||||
SeriesImages: seriesImageSet{
|
||||
ContentID: series.ContentID,
|
||||
PosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"),
|
||||
PosterPath: series.PosterPath,
|
||||
PosterThumbhash: series.PosterThumbhash,
|
||||
BackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
|
||||
BackdropPath: series.BackdropPath,
|
||||
BackdropThumbhash: series.BackdropThumbhash,
|
||||
UpdatedAt: series.UpdatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,9 +142,10 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
|
||||
libraries := make([]upstreamUserLibrary, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
lib := upstreamUserLibrary{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Type: f.Type,
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Type: f.Type,
|
||||
PosterPath: f.PosterPath,
|
||||
}
|
||||
if f.PosterPath != "" && s.posterPresigner != nil {
|
||||
ttl := s.presignTTL
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
)
|
||||
|
||||
const autoscanTrigger = "jellyfin_autoscan"
|
||||
|
||||
type autoscanFolderRepository interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
|
||||
List(ctx context.Context) ([]*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
type autoscanVirtualFolderFallback interface {
|
||||
HandleVirtualFolders(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type AutoscanHandler struct {
|
||||
folders autoscanFolderRepository
|
||||
queue scantrigger.Queuer
|
||||
codec *ResourceIDCodec
|
||||
fallback autoscanVirtualFolderFallback
|
||||
}
|
||||
|
||||
func NewAutoscanHandler(
|
||||
folders autoscanFolderRepository,
|
||||
queue scantrigger.Queuer,
|
||||
codec *ResourceIDCodec,
|
||||
fallback autoscanVirtualFolderFallback,
|
||||
) *AutoscanHandler {
|
||||
if codec == nil {
|
||||
codec = NewResourceIDCodec()
|
||||
}
|
||||
return &AutoscanHandler{folders: folders, queue: queue, codec: codec, fallback: fallback}
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available")
|
||||
return
|
||||
}
|
||||
if !AdminAPIKeyFromContext(r.Context()) {
|
||||
if h.fallback != nil {
|
||||
h.fallback.HandleVirtualFolders(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
||||
return
|
||||
}
|
||||
if h.folders == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available")
|
||||
return
|
||||
}
|
||||
folders, err := h.folders.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("jellycompat autoscan: listing libraries", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to list libraries")
|
||||
return
|
||||
}
|
||||
resp := make([]virtualFolderDTO, 0, len(folders))
|
||||
for _, folder := range folders {
|
||||
if folder == nil || !folder.Enabled {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, virtualFolderDTO{
|
||||
Name: folder.Name,
|
||||
Locations: folder.Paths,
|
||||
CollectionType: libraryCollectionType(folder.Type),
|
||||
ItemID: h.codec.EncodeIntID(EncodedIDLibrary, int64(folder.ID)),
|
||||
LibraryOptions: virtualLibraryOptDTO{
|
||||
Enabled: true,
|
||||
EnableRealtimeMonitor: true,
|
||||
EnableInternetProviders: true,
|
||||
SeasonZeroDisplayName: "Specials",
|
||||
TypeOptions: []string{},
|
||||
},
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type mediaUpdatedRequest struct {
|
||||
Updates []mediaUpdatedEntry `json:"Updates"`
|
||||
}
|
||||
|
||||
type mediaUpdatedEntry struct {
|
||||
Path string `json:"path"`
|
||||
UpdateType string `json:"updateType"`
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.folders == nil || h.queue == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
|
||||
return
|
||||
}
|
||||
var req mediaUpdatedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if len(req.Updates) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required")
|
||||
return
|
||||
}
|
||||
resolver := scantrigger.NewResolver(h.folders)
|
||||
targets := make([]scantrigger.Target, 0, len(req.Updates))
|
||||
seenTargets := make(map[autoscanTargetKey]struct{}, len(req.Updates))
|
||||
for _, update := range req.Updates {
|
||||
path := strings.TrimSpace(update.Path)
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "Update path is required")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := resolver.Resolve(r.Context(), scantrigger.Request{
|
||||
Path: path,
|
||||
Trigger: autoscanTrigger,
|
||||
})
|
||||
if err != nil {
|
||||
if parentTarget, handled, fallbackErr := resolveAutoscanParentTarget(r.Context(), resolver, path, err); handled {
|
||||
if fallbackErr != nil {
|
||||
slog.Warn("jellycompat autoscan: media update parent path rejected",
|
||||
"path", path,
|
||||
"parent_path", filepath.Dir(filepath.Clean(path)),
|
||||
"update_type", update.UpdateType,
|
||||
"error", fallbackErr,
|
||||
)
|
||||
writeScanTriggerError(w, fallbackErr)
|
||||
return
|
||||
}
|
||||
if parentTarget != nil {
|
||||
slog.Debug("jellycompat autoscan: media update falling back to parent scan",
|
||||
"path", path,
|
||||
"parent_path", parentTarget.Path,
|
||||
"parent_mode", parentTarget.Mode,
|
||||
"update_type", update.UpdateType,
|
||||
)
|
||||
targets = appendAutoscanTarget(targets, seenTargets, parentTarget)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if softAutoscanUpdateError(err) {
|
||||
slog.Debug("jellycompat autoscan: media update ignored",
|
||||
"path", path,
|
||||
"update_type", update.UpdateType,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
slog.Warn("jellycompat autoscan: media update path rejected",
|
||||
"path", path,
|
||||
"update_type", update.UpdateType,
|
||||
"error", err,
|
||||
)
|
||||
writeScanTriggerError(w, err)
|
||||
return
|
||||
}
|
||||
targets = appendAutoscanTarget(targets, seenTargets, target)
|
||||
}
|
||||
targets = compactAutoscanTargets(targets)
|
||||
if len(targets) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil {
|
||||
writeScanTriggerError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type autoscanTargetKey struct {
|
||||
folderID int
|
||||
mode string
|
||||
path string
|
||||
trigger string
|
||||
}
|
||||
|
||||
func appendAutoscanTarget(
|
||||
targets []scantrigger.Target,
|
||||
seen map[autoscanTargetKey]struct{},
|
||||
target *scantrigger.Target,
|
||||
) []scantrigger.Target {
|
||||
if target == nil {
|
||||
return targets
|
||||
}
|
||||
folderID := 0
|
||||
if target.Folder != nil {
|
||||
folderID = target.Folder.ID
|
||||
}
|
||||
key := autoscanTargetKey{
|
||||
folderID: folderID,
|
||||
mode: target.Mode,
|
||||
path: target.Path,
|
||||
trigger: target.Trigger,
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return targets
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
return append(targets, *target)
|
||||
}
|
||||
|
||||
func compactAutoscanTargets(targets []scantrigger.Target) []scantrigger.Target {
|
||||
if len(targets) < 2 {
|
||||
return targets
|
||||
}
|
||||
compacted := make([]scantrigger.Target, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
if autoscanTargetCoveredByOther(target, targets, i) {
|
||||
continue
|
||||
}
|
||||
compacted = append(compacted, target)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
func autoscanTargetCoveredByOther(target scantrigger.Target, targets []scantrigger.Target, index int) bool {
|
||||
if target.Folder == nil || target.Mode == scantrigger.ModeLibrary {
|
||||
return false
|
||||
}
|
||||
for i, other := range targets {
|
||||
if i == index || other.Folder == nil || other.Folder.ID != target.Folder.ID || other.Trigger != target.Trigger {
|
||||
continue
|
||||
}
|
||||
switch other.Mode {
|
||||
case scantrigger.ModeLibrary:
|
||||
return true
|
||||
case scantrigger.ModeSubtree:
|
||||
if target.Path != "" && scantrigger.PathWithinRoot(target.Path, other.Path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveAutoscanParentTarget(
|
||||
ctx context.Context,
|
||||
resolver *scantrigger.Resolver,
|
||||
path string,
|
||||
err error,
|
||||
) (*scantrigger.Target, bool, error) {
|
||||
if !parentFallbackAutoscanUpdateError(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
cleanPath := filepath.Clean(path)
|
||||
parentPath := filepath.Dir(cleanPath)
|
||||
if parentPath == "." || parentPath == cleanPath {
|
||||
return nil, true, nil
|
||||
}
|
||||
target, parentErr := resolver.Resolve(ctx, scantrigger.Request{
|
||||
Path: parentPath,
|
||||
Trigger: autoscanTrigger,
|
||||
})
|
||||
if parentErr == nil {
|
||||
if target.Mode == scantrigger.ModeLibrary {
|
||||
return nil, true, nil
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
if softAutoscanUpdateError(parentErr) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return nil, true, parentErr
|
||||
}
|
||||
|
||||
func parentFallbackAutoscanUpdateError(err error) bool {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
switch reqErr.Message {
|
||||
case "Path does not exist",
|
||||
"Path must be a file or directory",
|
||||
"Unsupported media file extension":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func softAutoscanUpdateError(err error) bool {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
switch reqErr.Message {
|
||||
case "No library matches the given path",
|
||||
"Path does not exist",
|
||||
"Path must be a file or directory",
|
||||
"Unsupported media file extension":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeScanTriggerError(w http.ResponseWriter, err error) {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if errors.As(err, &reqErr) {
|
||||
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
|
||||
return
|
||||
}
|
||||
slog.Error("jellycompat autoscan: scan update failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to process scan update")
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
)
|
||||
|
||||
type fakeAutoscanFolders struct {
|
||||
folders []*models.MediaFolder
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanFolders) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
|
||||
for _, folder := range f.folders {
|
||||
if folder.ID == id {
|
||||
return folder, nil
|
||||
}
|
||||
}
|
||||
return nil, catalog.ErrFolderNotFound
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanFolders) List(context.Context) ([]*models.MediaFolder, error) {
|
||||
return f.folders, nil
|
||||
}
|
||||
|
||||
type fakeAutoscanQueue struct {
|
||||
calls []queuedScan
|
||||
batches [][]scantrigger.Target
|
||||
batchErr error
|
||||
}
|
||||
|
||||
type queuedScan struct {
|
||||
libraryID int
|
||||
mode string
|
||||
path string
|
||||
trigger string
|
||||
}
|
||||
|
||||
func (q *fakeAutoscanQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) {
|
||||
q.calls = append(q.calls, queuedScan{libraryID: folderID, mode: mode, path: path, trigger: trigger})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (q *fakeAutoscanQueue) EnqueueScans(_ context.Context, targets []scantrigger.Target) error {
|
||||
copied := append([]scantrigger.Target(nil), targets...)
|
||||
q.batches = append(q.batches, copied)
|
||||
if q.batchErr != nil {
|
||||
return q.batchErr
|
||||
}
|
||||
for _, target := range targets {
|
||||
folderID := 0
|
||||
if target.Folder != nil {
|
||||
folderID = target.Folder.ID
|
||||
}
|
||||
q.calls = append(q.calls, queuedScan{
|
||||
libraryID: folderID,
|
||||
mode: target.Mode,
|
||||
path: target.Path,
|
||||
trigger: target.Trigger,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAutoscanVirtualFoldersIncludesEnabledLocationsForAdminKey(t *testing.T) {
|
||||
enabledRoot := t.TempDir()
|
||||
disabledRoot := t.TempDir()
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{
|
||||
{ID: 1, Name: "Movies", Type: "movie", Enabled: true, Paths: []string{enabledRoot}},
|
||||
{ID: 2, Name: "Disabled", Type: "movie", Enabled: false, Paths: []string{disabledRoot}},
|
||||
}}, nil, NewResourceIDCodec(), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), adminAPIKeyKey, true))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVirtualFolders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got []virtualFolderDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one enabled library, got %d", len(got))
|
||||
}
|
||||
if got[0].Name != "Movies" || len(got[0].Locations) != 1 || got[0].Locations[0] != enabledRoot {
|
||||
t.Fatalf("unexpected folder response: %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 3,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
|
||||
}
|
||||
if len(queue.batches) != 1 {
|
||||
t.Fatalf("expected one batch enqueue, got %d", len(queue.batches))
|
||||
}
|
||||
if queue.calls[0].libraryID != 3 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedRejectsAmbiguousLibraryWithoutPartialEnqueue(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ambiguousRoot := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
ambiguousPath := filepath.Join(ambiguousRoot, "Other.mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(ambiguousPath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{
|
||||
{
|
||||
ID: 4,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Name: "Movies A",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{ambiguousRoot},
|
||||
},
|
||||
{
|
||||
ID: 6,
|
||||
Name: "Movies B",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{ambiguousRoot},
|
||||
},
|
||||
}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": filePath, "updateType": "Modified"},
|
||||
{"path": ambiguousPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 0 {
|
||||
t.Fatalf("expected no partial enqueue, got %#v", queue.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedIgnoresUnsupportedSidecars(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2024)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(movieDir, "Movie.mkv")
|
||||
nfoPath := filepath.Join(movieDir, "Movie.nfo")
|
||||
posterPath := filepath.Join(movieDir, "poster.jpg")
|
||||
for _, path := range []string{filePath, nfoPath, posterPath} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 5,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": nfoPath, "updateType": "Modified"},
|
||||
{"path": filePath, "updateType": "Modified"},
|
||||
{"path": posterPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one parent scan, got %#v", queue.calls)
|
||||
}
|
||||
if queue.calls[0].libraryID != 5 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected parent scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedSidecarsScanParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2024)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfoPath := filepath.Join(movieDir, "Movie.nfo")
|
||||
posterPath := filepath.Join(movieDir, "poster.jpg")
|
||||
for _, path := range []string{nfoPath, posterPath} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 6,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": nfoPath, "updateType": "Modified"},
|
||||
{"path": posterPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one parent scan, got %#v", queue.calls)
|
||||
}
|
||||
if queue.calls[0].libraryID != 6 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected parent scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedRootSidecarDoesNotScanLibrary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
anchorPath := filepath.Join(root, ".plexignore")
|
||||
if err := os.WriteFile(anchorPath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 7,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": anchorPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 0 {
|
||||
t.Fatalf("expected no queued scans, got %#v", queue.calls)
|
||||
}
|
||||
if len(queue.batches) != 0 {
|
||||
t.Fatalf("expected no batch enqueue, got %#v", queue.batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedFallsBackToParentForMissingFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2024)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(movieDir, "Movie.mkv")
|
||||
missingPath := filepath.Join(movieDir, "pollermovie.mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 8,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": missingPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
|
||||
}
|
||||
if queue.calls[0].libraryID != 8 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedIgnoresUnmatchedLibraryPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
unmatchedRoot := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
unmatchedPath := filepath.Join(unmatchedRoot, "Other.mkv")
|
||||
for _, path := range []string{filePath, unmatchedPath} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 9,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": unmatchedPath, "updateType": "Modified"},
|
||||
{"path": filePath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
|
||||
}
|
||||
if queue.calls[0].libraryID != 9 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedHidesInternalQueueError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{batchErr: errors.New("database password leaked")}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 10,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("database password leaked")) {
|
||||
t.Fatalf("response leaked internal error: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// ImagesHandler serves Jellyfin-compatible image routes.
|
||||
@@ -18,14 +21,35 @@ type ImagesHandler struct {
|
||||
images *ImageCache
|
||||
personRepo *catalog.PersonRepository
|
||||
detailSvc *catalog.DetailService
|
||||
itemRepo *catalog.ItemRepository
|
||||
seasonRepo *catalog.SeasonRepository
|
||||
episodeRepo *catalog.EpisodeRepository
|
||||
itemRepo imageItemRepository
|
||||
folderRepo imageFolderRepository
|
||||
seasonRepo imageSeasonRepository
|
||||
episodeRepo imageEpisodeRepository
|
||||
accessFilter AccessFilterResolver
|
||||
posterSigner LibraryPosterPresigner
|
||||
presignTTL time.Duration
|
||||
imageTags *imageTagSigner
|
||||
}
|
||||
|
||||
type imageItemRepository interface {
|
||||
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
|
||||
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
||||
}
|
||||
|
||||
type imageSeasonRepository interface {
|
||||
GetByID(ctx context.Context, contentID string) (*models.Season, error)
|
||||
}
|
||||
|
||||
type imageEpisodeRepository interface {
|
||||
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
|
||||
}
|
||||
|
||||
type imageFolderRepository interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
// NewImagesHandler creates an image proxy handler.
|
||||
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver) *ImagesHandler {
|
||||
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
@@ -38,9 +62,13 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient
|
||||
personRepo: personRepo,
|
||||
detailSvc: detailSvc,
|
||||
itemRepo: itemRepo,
|
||||
folderRepo: folderRepo,
|
||||
seasonRepo: seasonRepo,
|
||||
episodeRepo: episodeRepo,
|
||||
accessFilter: accessFilter,
|
||||
posterSigner: posterSigner,
|
||||
presignTTL: presignTTL,
|
||||
imageTags: newImageTagSigner(imageTagSecret),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +79,23 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
|
||||
routeID := chiURLParam(r, "id")
|
||||
imageType := chiURLParam(r, "imageType")
|
||||
imageSize := compatRequestImageSize(r, imageType)
|
||||
if imageURL, ok := h.images.LookupSized(routeID, imageType, r.URL.Query().Get("tag"), imageSize); ok {
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
if tag != "" {
|
||||
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
|
||||
if err != nil {
|
||||
writeCompatUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
|
||||
h.proxyImageURL(w, r, imageURL.URL)
|
||||
return
|
||||
}
|
||||
if imageURL, ok := h.images.LookupTag(tag); ok {
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
@@ -202,6 +246,148 @@ func (h *ImagesHandler) resolveItemImageURLFromRepos(ctx context.Context, sessio
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
|
||||
if h.imageTags == nil || tag == "" {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, routeID); err == nil {
|
||||
return h.resolveLibraryImageURLFromTag(ctx, routeID, int(libraryID), imageType, imageSize, tag)
|
||||
}
|
||||
contentID, err := decodeContentID(h.codec, routeID)
|
||||
if err != nil {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
return h.resolveItemImageURLFromReposWithoutSession(ctx, routeID, contentID, imageType, imageSize, tag)
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) resolveLibraryImageURLFromTag(ctx context.Context, routeID string, libraryID int, imageType, _ string, tag string) (catalog.ResolvedImageURL, bool, error) {
|
||||
if imageType != "Primary" || h.folderRepo == nil || h.posterSigner == nil {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
folder, err := h.folderRepo.GetByID(ctx, libraryID)
|
||||
if err != nil {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
if folder.PosterPath == "" || !h.imageTags.Equal(
|
||||
imageTagSeed(routeID, "Primary", compatCardImageSize, folder.PosterPath, "", time.Time{}),
|
||||
"",
|
||||
tag,
|
||||
) {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
imageURL := h.presignLibraryPosterURL(ctx, folder.PosterPath)
|
||||
if imageURL == "" {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
return catalog.ResolvedImageURL{URL: imageURL}, true, nil
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) presignLibraryPosterURL(ctx context.Context, posterPath string) string {
|
||||
if posterPath == "" || h.posterSigner == nil {
|
||||
return ""
|
||||
}
|
||||
ttl := h.presignTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 4 * time.Hour
|
||||
}
|
||||
imageURL, err := h.posterSigner.PresignGetURL(ctx, h.posterSigner.Bucket(), posterPath, ttl)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, routeID, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
|
||||
if h.itemRepo != nil {
|
||||
if item, err := h.itemRepo.GetByID(ctx, contentID); err == nil {
|
||||
if imageURL := h.imageURLForItem(ctx, item.PosterPath, "poster", item.BackdropPath, item.LogoPath, imageType, imageSize); imageURL.URL != "" {
|
||||
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt, imageURL.URL) {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
return imageURL, true, nil
|
||||
}
|
||||
} else if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.episodeRepo != nil && h.itemRepo != nil {
|
||||
if episode, err := h.episodeRepo.GetByID(ctx, contentID); err == nil {
|
||||
series, seriesErr := h.itemRepo.GetByID(ctx, episode.SeriesID)
|
||||
if seriesErr != nil {
|
||||
if !errors.Is(seriesErr, catalog.ErrItemNotFound) {
|
||||
return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr)
|
||||
}
|
||||
} else {
|
||||
if imageURL := h.imageURLForItem(ctx, episode.StillPath, "still", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" {
|
||||
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt, imageURL.URL) {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
return imageURL, true, nil
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, catalog.ErrEpisodeNotFound) {
|
||||
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.seasonRepo != nil && h.itemRepo != nil {
|
||||
if season, err := h.seasonRepo.GetByID(ctx, contentID); err == nil {
|
||||
series, seriesErr := h.itemRepo.GetByID(ctx, season.SeriesID)
|
||||
if seriesErr != nil {
|
||||
if errors.Is(seriesErr, catalog.ErrItemNotFound) {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr)
|
||||
}
|
||||
if imageURL := h.imageURLForItem(ctx, season.PosterPath, "poster", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" {
|
||||
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt, imageURL.URL) {
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
return imageURL, true, nil
|
||||
}
|
||||
} else if !errors.Is(err, catalog.ErrSeasonNotFound) {
|
||||
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return catalog.ResolvedImageURL{}, false, nil
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) signedImageTagMatches(routeID, contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time, resolvedURL string) bool {
|
||||
var path, thumbhash, tagImageType string
|
||||
switch imageType {
|
||||
case "Primary":
|
||||
path = primaryPath
|
||||
thumbhash = primaryThumbhash
|
||||
tagImageType = "Primary"
|
||||
case "Backdrop", "Thumb":
|
||||
path = backdropPath
|
||||
thumbhash = backdropThumbhash
|
||||
tagImageType = "Backdrop"
|
||||
case "Logo":
|
||||
path = logoPath
|
||||
tagImageType = "Logo"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if path != "" && h.imageTags.Equal(
|
||||
imageTagSeed(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt),
|
||||
path,
|
||||
tag,
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if resolvedURL == "" {
|
||||
return false
|
||||
}
|
||||
return h.imageTags.Equal(
|
||||
imageTagSeed(routeID, tagImageType, compatCardImageSize, resolvedURL, "", time.Time{}),
|
||||
resolvedURL,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) imageURLForItem(ctx context.Context, primaryPath, primaryImageType, backdropPath, logoPath, imageType, size string) catalog.ResolvedImageURL {
|
||||
primaryURL := compatPresignImageWithExpiry(h.detailSvc, ctx, primaryPath, primaryImageType, size)
|
||||
backdropURL := compatPresignImageWithExpiry(h.detailSvc, ctx, backdropPath, "backdrop", size)
|
||||
|
||||
@@ -212,26 +212,8 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
|
||||
seriesRouteID := h.codec.EncodeStringID(EncodedIDItem, detail.SeriesID)
|
||||
cachedPoster, _ := h.images.LookupSized(seriesRouteID, "Primary", "", compatCardImageSize)
|
||||
cachedBackdrop, _ := h.images.LookupSized(seriesRouteID, "Backdrop", "", compatCardImageSize)
|
||||
|
||||
if cachedPoster != "" && cachedBackdrop != "" {
|
||||
// Both poster and backdrop hit — populate from cache and skip the
|
||||
// second GetItemDetail call against the parent series. Cache is
|
||||
// populated by browse/list/recommendation responses for the series.
|
||||
// Audit 2026-05-01 §3.4. We require BOTH because a partial hit
|
||||
// (only one URL cached) would silently degrade the response — the
|
||||
// fallback fetch can populate both.
|
||||
h.mapper.applySeriesImages(&dto, cachedPoster, cachedBackdrop)
|
||||
if h.images != nil {
|
||||
h.images.RememberSized(dto.SeriesID, "Thumb", cachedBackdrop, compatCardImageSize)
|
||||
}
|
||||
} else {
|
||||
// Cache miss or partial — fall back to original series-detail fetch.
|
||||
seriesImgCache := make(map[string]seriesImageURLs)
|
||||
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
|
||||
}
|
||||
seriesImgCache := make(map[string]seriesImageSet)
|
||||
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
|
||||
if detail.SeasonNumber != nil {
|
||||
season, seasonErr := h.content.GetSeason(r.Context(), session, detail.SeriesID, *detail.SeasonNumber, nil)
|
||||
if seasonErr == nil && season != nil {
|
||||
@@ -1910,22 +1892,22 @@ func (h *ItemsHandler) presignCompatImagePath(ctx context.Context, path, imageTy
|
||||
return compatPresignImage(h.detailSvc, ctx, path, imageType, compatCardImageSize)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL, seriesPosterURL, seriesBackdropURL string) {
|
||||
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL string, series seriesImageSet) {
|
||||
if h.images == nil {
|
||||
return
|
||||
}
|
||||
h.images.RememberSized(dto.ID, "Primary", stillURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.ID, "Backdrop", seriesBackdropURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.ID, "Backdrop", series.BackdropURL, compatCardImageSize)
|
||||
if dto.SeriesID != "" {
|
||||
h.images.RememberSized(dto.SeriesID, "Primary", seriesPosterURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.SeriesID, "Backdrop", seriesBackdropURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.SeriesID, "Thumb", seriesBackdropURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.SeriesID, "Primary", series.PosterURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.SeriesID, "Backdrop", series.BackdropURL, compatCardImageSize)
|
||||
h.images.RememberSized(dto.SeriesID, "Thumb", series.BackdropURL, compatCardImageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
|
||||
h.mapper.applySeriesImages(dto, target.SeriesPosterURL, target.SeriesBackdropURL)
|
||||
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesPosterURL, target.SeriesBackdropURL)
|
||||
h.mapper.applySeriesImages(dto, target.SeriesImages)
|
||||
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listSeriesEpisodes(ctx context.Context, session *Session, seriesID string, seasons []upstreamSeason, requestedSeasonID string) ([]*models.Episode, error) {
|
||||
@@ -2164,17 +2146,10 @@ func (h *ItemsHandler) rememberEpisodeImages(episodes []upstreamEpisode) {
|
||||
}
|
||||
}
|
||||
|
||||
// seriesImageURLs holds poster/backdrop URLs for a series, used to populate
|
||||
// series image tags on episode DTOs for clients like Infuse.
|
||||
type seriesImageURLs struct {
|
||||
posterURL string
|
||||
backdropURL string
|
||||
}
|
||||
|
||||
// enrichEpisodeSeriesImages looks up the parent series poster/backdrop and
|
||||
// applies them to an episode DTO. The cache avoids repeated lookups when
|
||||
// multiple episodes belong to the same series.
|
||||
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageURLs) {
|
||||
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageSet) {
|
||||
if seriesContentID == "" || dto.SeriesID == "" {
|
||||
return
|
||||
}
|
||||
@@ -2182,14 +2157,23 @@ func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *S
|
||||
if !ok {
|
||||
detail, err := h.content.GetItemDetail(ctx, session, seriesContentID, nil)
|
||||
if err == nil {
|
||||
imgs = seriesImageURLs{posterURL: detail.PosterURL, backdropURL: detail.BackdropURL}
|
||||
imgs = seriesImageSet{
|
||||
ContentID: detail.ContentID,
|
||||
PosterURL: detail.PosterURL,
|
||||
PosterPath: detail.PosterPath,
|
||||
PosterThumbhash: detail.PosterThumbhash,
|
||||
BackdropURL: detail.BackdropURL,
|
||||
BackdropPath: detail.BackdropPath,
|
||||
BackdropThumbhash: detail.BackdropThumbhash,
|
||||
UpdatedAt: detail.UpdatedAt,
|
||||
}
|
||||
h.rememberDetailImages(*detail)
|
||||
}
|
||||
cache[seriesContentID] = imgs
|
||||
}
|
||||
h.mapper.applySeriesImages(dto, imgs.posterURL, imgs.backdropURL)
|
||||
if imgs.backdropURL != "" && h.images != nil {
|
||||
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.backdropURL, compatCardImageSize)
|
||||
h.mapper.applySeriesImages(dto, imgs)
|
||||
if imgs.BackdropURL != "" && h.images != nil {
|
||||
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.BackdropURL, compatCardImageSize)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,11 +70,11 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
// TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail verifies that when an
|
||||
// episode detail is requested and the series's poster/backdrop are already in
|
||||
// the ImageCache (e.g. from a prior browse response), the handler does NOT
|
||||
// fetch the parent series detail a second time. Audit 2026-05-01 §3.4.
|
||||
func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) {
|
||||
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
|
||||
// that episode detail responses fetch parent series image metadata even when
|
||||
// image URLs are already cached. Cached URLs are not enough to build stable
|
||||
// signed tags after Jellycompat restarts.
|
||||
func TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
episodeContentID := "ep1"
|
||||
seriesContentID := "series-1"
|
||||
@@ -123,8 +123,8 @@ func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) {
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if contentSvc.getItemDetailCalls != 1 {
|
||||
t.Errorf("expected exactly 1 GetItemDetail (episode only); got %d",
|
||||
if contentSvc.getItemDetailCalls != 2 {
|
||||
t.Errorf("expected episode and series GetItemDetail calls for stable parent image tags; got %d",
|
||||
contentSvc.getItemDetailCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +94,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
|
||||
}
|
||||
|
||||
if tag = strings.TrimSpace(tag); tag != "" {
|
||||
if url, ok := c.lookupTag(tag); ok {
|
||||
return url, true
|
||||
}
|
||||
return c.LookupTag(tag)
|
||||
}
|
||||
|
||||
if routeID == "" || imageType == "" {
|
||||
@@ -105,6 +103,14 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
|
||||
return c.lookupRoute(routeImageKey(routeID, imageType, size))
|
||||
}
|
||||
|
||||
// LookupTag resolves a cached image URL only by its legacy URL-derived tag.
|
||||
func (c *ImageCache) LookupTag(tag string) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.lookupTag(strings.TrimSpace(tag))
|
||||
}
|
||||
|
||||
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
|
||||
// presigned URL: for S3-cached paths the size variant is embedded in the URL
|
||||
// (so different sizes produce different tags), and for HTTP-passthrough URLs
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const imageTagSignatureDomain = "silo:jellycompat:image-tag:v1"
|
||||
|
||||
type imageTagSigner struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func newImageTagSigner(secret string) *imageTagSigner {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil
|
||||
}
|
||||
return &imageTagSigner{secret: []byte(secret)}
|
||||
}
|
||||
|
||||
func (s *imageTagSigner) Tag(seed, fallbackURL string) string {
|
||||
if strings.TrimSpace(seed) == "" {
|
||||
return tagValue(fallbackURL)
|
||||
}
|
||||
if s == nil {
|
||||
return tagValue(seed)
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
_, _ = mac.Write([]byte(imageTagSignatureDomain))
|
||||
_, _ = mac.Write([]byte{0})
|
||||
_, _ = mac.Write([]byte(seed))
|
||||
sum := mac.Sum(nil)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
actual = strings.TrimSpace(actual)
|
||||
expected := s.Tag(seed, fallbackURL)
|
||||
if expected == "" || actual == "" || len(expected) != len(actual) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) {
|
||||
@@ -86,3 +94,302 @@ func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 304", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
codec := NewResourceIDCodec()
|
||||
contentID := "movie-1"
|
||||
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
|
||||
item := &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
PosterPath: upstream.URL,
|
||||
PosterThumbhash: "poster-thumbhash",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
|
||||
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
|
||||
ContentID: contentID,
|
||||
Type: "movie",
|
||||
Title: "Movie",
|
||||
PosterURL: item.PosterPath,
|
||||
PosterPath: item.PosterPath,
|
||||
PosterThumbhash: item.PosterThumbhash,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
|
||||
req = withImageRouteParams(req, routeID, "Primary")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "image-bytes" {
|
||||
t.Fatalf("body = %q, want image bytes", got)
|
||||
}
|
||||
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
|
||||
t.Fatal("signed-tag image URL was not cached after resolution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
contentID := "movie-1"
|
||||
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
|
||||
item := &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
PosterPath: "https://cdn.example.test/poster.jpg",
|
||||
PosterThumbhash: "poster-thumbhash",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
tag := newMapper(codec, &config.Config{}).itemFromList(upstreamListItem{
|
||||
ContentID: contentID,
|
||||
Type: "movie",
|
||||
Title: "Movie",
|
||||
PosterURL: item.PosterPath,
|
||||
PosterPath: item.PosterPath,
|
||||
PosterThumbhash: item.PosterThumbhash,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: http.DefaultClient,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(""),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
|
||||
req = withImageRouteParams(req, routeID, "Primary")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("backdrop-bytes"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
codec := NewResourceIDCodec()
|
||||
contentID := "series-1"
|
||||
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
secret := "image-secret"
|
||||
tag := newImageTagSigner(secret).Tag(
|
||||
imageTagSeed(contentID, "Backdrop", compatCardImageSize, upstream.URL, "", time.Time{}),
|
||||
upstream.URL,
|
||||
)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
BackdropPath: upstream.URL,
|
||||
}},
|
||||
imageTags: newImageTagSigner(secret),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
|
||||
req = withImageRouteParams(req, routeID, "Thumb")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "backdrop-bytes" {
|
||||
t.Fatalf("body = %q, want backdrop bytes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("library-poster"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
codec := NewResourceIDCodec()
|
||||
libraryID := 1
|
||||
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(libraryID))
|
||||
posterPath := "library-posters/1/original.jpg"
|
||||
secret := "image-secret"
|
||||
tag := newImageTagSigner(secret).Tag(
|
||||
imageTagSeed(routeID, "Primary", compatCardImageSize, posterPath, "", time.Time{}),
|
||||
"",
|
||||
)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}},
|
||||
posterSigner: fakeLibraryPosterPresigner{url: upstream.URL},
|
||||
imageTags: newImageTagSigner(secret),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
|
||||
req = withImageRouteParams(req, routeID, "Primary")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "library-poster" {
|
||||
t.Fatalf("body = %q, want library poster", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-image"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
codec := NewResourceIDCodec()
|
||||
routeID := codec.EncodeStringID(EncodedIDItem, "movie-1")
|
||||
cache := NewImageCache(time.Hour, time.Now)
|
||||
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: cache,
|
||||
imageTags: newImageTagSigner("image-secret"),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil)
|
||||
req = withImageRouteParams(req, routeID, "Primary")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "cached-image" {
|
||||
t.Fatalf("body = %q, want cached image", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
|
||||
called := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte("stale-image"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
codec := NewResourceIDCodec()
|
||||
contentID := "movie-1"
|
||||
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
|
||||
item := &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
PosterPath: upstream.URL,
|
||||
PosterThumbhash: "poster-thumbhash",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
cache := NewImageCache(time.Hour, func() time.Time { return updatedAt })
|
||||
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
|
||||
tag := newMapper(codec, &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: "old-secret"},
|
||||
}).itemFromList(upstreamListItem{
|
||||
ContentID: contentID,
|
||||
Type: "movie",
|
||||
Title: "Movie",
|
||||
PosterURL: item.PosterPath,
|
||||
PosterPath: item.PosterPath,
|
||||
PosterThumbhash: item.PosterThumbhash,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: cache,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner("new-secret"),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
|
||||
req = withImageRouteParams(req, routeID, "Primary")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("served cached image before validating the signed tag")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeImageItemRepo struct {
|
||||
item *models.MediaItem
|
||||
}
|
||||
|
||||
func (r fakeImageItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
|
||||
if r.item != nil && r.item.ContentID == contentID {
|
||||
return r.item, nil
|
||||
}
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
|
||||
func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeImageFolderRepo struct {
|
||||
folder *models.MediaFolder
|
||||
}
|
||||
|
||||
func (r fakeImageFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
|
||||
if r.folder != nil && r.folder.ID == id {
|
||||
return r.folder, nil
|
||||
}
|
||||
return nil, catalog.ErrFolderNotFound
|
||||
}
|
||||
|
||||
type fakeLibraryPosterPresigner struct {
|
||||
url string
|
||||
}
|
||||
|
||||
func (p fakeLibraryPosterPresigner) PresignGetURL(context.Context, string, string, time.Duration) (string, error) {
|
||||
return p.url, nil
|
||||
}
|
||||
|
||||
func (p fakeLibraryPosterPresigner) Bucket() string {
|
||||
return "test-bucket"
|
||||
}
|
||||
|
||||
func withImageRouteParams(r *http.Request, routeID, imageType string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", routeID)
|
||||
routeCtx.URLParams.Add("imageType", imageType)
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
@@ -23,26 +23,33 @@ var allDetailFields = map[string]bool{
|
||||
}
|
||||
|
||||
type mapper struct {
|
||||
codec *ResourceIDCodec
|
||||
serverID string
|
||||
codec *ResourceIDCodec
|
||||
serverID string
|
||||
imageTagSigner *imageTagSigner
|
||||
}
|
||||
|
||||
func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper {
|
||||
serverID := ""
|
||||
imageTagSecret := ""
|
||||
if cfg != nil {
|
||||
serverID = cfg.JellyfinCompat.ServerID
|
||||
imageTagSecret = cfg.Auth.JWTSecret
|
||||
}
|
||||
return &mapper{codec: codec, serverID: serverID}
|
||||
return &mapper{codec: codec, serverID: serverID, imageTagSigner: newImageTagSigner(imageTagSecret)}
|
||||
}
|
||||
|
||||
func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
|
||||
imgTags := map[string]string{}
|
||||
if library.PosterURL != "" {
|
||||
imgTags["Primary"] = tagValue(library.PosterURL)
|
||||
routeID := m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
|
||||
if library.PosterPath != "" {
|
||||
imgTags["Primary"] = m.imageTagSigner.Tag(
|
||||
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
|
||||
library.PosterURL,
|
||||
)
|
||||
}
|
||||
|
||||
return baseItemDTO{
|
||||
ID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
|
||||
ID: routeID,
|
||||
Type: "CollectionFolder",
|
||||
MediaType: "Unknown",
|
||||
IsFolder: true,
|
||||
@@ -52,8 +59,8 @@ func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
|
||||
SortName: strings.ToLower(library.Name),
|
||||
ImageTags: imgTags,
|
||||
UserData: &itemUserDataDTO{
|
||||
Key: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
|
||||
ItemID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
|
||||
Key: routeID,
|
||||
ItemID: routeID,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -102,13 +109,14 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
|
||||
dto.ChildCount = *item.SeasonCount
|
||||
dto.RecursiveItemCount = *item.SeasonCount
|
||||
}
|
||||
if tags := imageTagsWithSeed(
|
||||
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash, item.UpdatedAt),
|
||||
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
|
||||
if tags := imageTagsWithSeed(m.imageTagSigner,
|
||||
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, primaryPath, primaryThumbhash, item.UpdatedAt),
|
||||
item.PosterURL,
|
||||
); tags != nil {
|
||||
dto.ImageTags = tags
|
||||
}
|
||||
if tags := backdropTagsWithSeed(
|
||||
if tags := backdropTagsWithSeed(m.imageTagSigner,
|
||||
imageTagSeed(item.ContentID, "Backdrop", compatCardImageSize, item.BackdropPath, item.BackdropThumbhash, item.UpdatedAt),
|
||||
item.BackdropURL,
|
||||
); tags != nil {
|
||||
@@ -397,7 +405,7 @@ func (m *mapper) seasonFromUpstream(season upstreamSeason, seriesID string, isFa
|
||||
RecursiveItemCount: season.EpisodeCount,
|
||||
}
|
||||
dto.IndexNumber = &season.SeasonNumber
|
||||
if tags := imageTagsWithSeed(
|
||||
if tags := imageTagsWithSeed(m.imageTagSigner,
|
||||
imageTagSeed(season.ContentID, "Primary", compatCardImageSize, season.PosterPath, season.PosterThumbhash, season.UpdatedAt),
|
||||
season.PosterURL,
|
||||
); tags != nil {
|
||||
@@ -430,7 +438,7 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre
|
||||
dto.SeasonID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
|
||||
dto.ParentID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
|
||||
}
|
||||
if tags := imageTagsWithSeed(
|
||||
if tags := imageTagsWithSeed(m.imageTagSigner,
|
||||
imageTagSeed(ep.ContentID, "Primary", compatCardImageSize, ep.StillPath, ep.StillThumbhash, ep.UpdatedAt),
|
||||
ep.StillURL,
|
||||
); tags != nil {
|
||||
@@ -439,19 +447,37 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre
|
||||
return dto
|
||||
}
|
||||
|
||||
type seriesImageSet struct {
|
||||
ContentID string
|
||||
PosterURL string
|
||||
PosterPath string
|
||||
PosterThumbhash string
|
||||
BackdropURL string
|
||||
BackdropPath string
|
||||
BackdropThumbhash string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// applySeriesImages sets series/parent image tags on an episode DTO so clients
|
||||
// can display the series poster and backdrop in Continue Watching / Next Up.
|
||||
func (m *mapper) applySeriesImages(dto *baseItemDTO, seriesPosterURL, seriesBackdropURL string) {
|
||||
func (m *mapper) applySeriesImages(dto *baseItemDTO, series seriesImageSet) {
|
||||
if dto.SeriesID == "" {
|
||||
return
|
||||
}
|
||||
if seriesPosterURL != "" {
|
||||
dto.SeriesPrimaryImageTag = tagValue(seriesPosterURL)
|
||||
if series.PosterURL != "" {
|
||||
dto.SeriesPrimaryImageTag = m.imageTagSigner.Tag(
|
||||
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, series.UpdatedAt),
|
||||
series.PosterURL,
|
||||
)
|
||||
}
|
||||
if seriesBackdropURL != "" {
|
||||
dto.ParentBackdropImageTags = backdropTags(seriesBackdropURL)
|
||||
if series.BackdropURL != "" {
|
||||
tag := m.imageTagSigner.Tag(
|
||||
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, series.UpdatedAt),
|
||||
series.BackdropURL,
|
||||
)
|
||||
dto.ParentBackdropImageTags = []string{tag}
|
||||
dto.ParentBackdropItemID = dto.SeriesID
|
||||
dto.ParentThumbImageTag = tagValue(seriesBackdropURL)
|
||||
dto.ParentThumbImageTag = tag
|
||||
dto.ParentThumbItemID = dto.SeriesID
|
||||
}
|
||||
}
|
||||
@@ -638,22 +664,29 @@ func resumePositionTicks(position, duration float64, played bool) int64 {
|
||||
return secondsToTicks(position)
|
||||
}
|
||||
|
||||
func imageTagsWithSeed(seed, imageURL string) map[string]string {
|
||||
func imageTagsWithSeed(signer *imageTagSigner, seed, imageURL string) map[string]string {
|
||||
if imageURL == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"Primary": imageTagValue(seed, imageURL)}
|
||||
return map[string]string{"Primary": signer.Tag(seed, imageURL)}
|
||||
}
|
||||
|
||||
func backdropTags(imageURL string) []string {
|
||||
return backdropTagsWithSeed("", imageURL)
|
||||
return backdropTagsWithSeed(nil, "", imageURL)
|
||||
}
|
||||
|
||||
func backdropTagsWithSeed(seed, imageURL string) []string {
|
||||
func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []string {
|
||||
if imageURL == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{imageTagValue(seed, imageURL)}
|
||||
return []string{signer.Tag(seed, imageURL)}
|
||||
}
|
||||
|
||||
func listItemPrimaryImageSeedParts(item upstreamListItem) (string, string) {
|
||||
if item.Type == "episode" && item.StillPath != "" {
|
||||
return item.StillPath, item.StillThumbhash
|
||||
}
|
||||
return firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash
|
||||
}
|
||||
|
||||
func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string {
|
||||
@@ -675,13 +708,6 @@ func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
func imageTagValue(seed, fallbackURL string) string {
|
||||
if seed != "" {
|
||||
return tagValue(seed)
|
||||
}
|
||||
return tagValue(fallbackURL)
|
||||
}
|
||||
|
||||
func tagValue(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
|
||||
@@ -55,3 +55,133 @@ func TestItemImageTagsFallbackToURLWhenCanonicalSeedMissing(t *testing.T) {
|
||||
t.Fatalf("fallback image tag did not change with URL: %q", first.ImageTags["Primary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemImageTagsUseConfiguredSecret(t *testing.T) {
|
||||
item := upstreamListItem{
|
||||
ContentID: "movie-1",
|
||||
Type: "movie",
|
||||
Title: "Movie",
|
||||
PosterURL: "https://cdn.example.test/poster.jpg?sig=one",
|
||||
PosterPath: "metadb://poster/movie-1",
|
||||
PosterThumbhash: "thumbhash",
|
||||
UpdatedAt: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
first := newMapper(NewResourceIDCodec(), &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: "secret-one"},
|
||||
}).itemFromList(item, false, nil, nil)
|
||||
second := newMapper(NewResourceIDCodec(), &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: "secret-two"},
|
||||
}).itemFromList(item, false, nil, nil)
|
||||
|
||||
if first.ImageTags["Primary"] == second.ImageTags["Primary"] {
|
||||
t.Fatalf("signed image tag did not change with configured secret: %q", first.ImageTags["Primary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) {
|
||||
secret := "image-secret"
|
||||
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
item := upstreamListItem{
|
||||
ContentID: "episode-1",
|
||||
Type: "episode",
|
||||
Title: "Episode",
|
||||
PosterURL: "https://cdn.example.test/still.jpg?sig=one",
|
||||
PosterPath: "metadb://still/episode-1",
|
||||
PosterThumbhash: "poster-thumbhash",
|
||||
StillPath: "metadb://still/episode-1",
|
||||
StillThumbhash: "still-thumbhash",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
dto := newMapper(NewResourceIDCodec(), &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: secret},
|
||||
}).itemFromList(item, false, nil, nil)
|
||||
expected := newImageTagSigner(secret).Tag(
|
||||
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, item.StillPath, item.StillThumbhash, updatedAt),
|
||||
item.PosterURL,
|
||||
)
|
||||
|
||||
if dto.ImageTags["Primary"] != expected {
|
||||
t.Fatalf("primary tag = %q, want still-thumbhash seed %q", dto.ImageTags["Primary"], expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryImageTagsUseStablePosterPath(t *testing.T) {
|
||||
secret := "image-secret"
|
||||
codec := NewResourceIDCodec()
|
||||
library := upstreamUserLibrary{
|
||||
ID: 1,
|
||||
Name: "Movies",
|
||||
Type: "movies",
|
||||
PosterURL: "https://cdn.example.test/library.jpg?sig=one",
|
||||
PosterPath: "library-posters/1/original.jpg",
|
||||
}
|
||||
|
||||
first := newMapper(codec, &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: secret},
|
||||
}).viewFromLibrary(library)
|
||||
library.PosterURL = "https://cdn.example.test/library.jpg?sig=two"
|
||||
second := newMapper(codec, &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: secret},
|
||||
}).viewFromLibrary(library)
|
||||
|
||||
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
|
||||
expected := newImageTagSigner(secret).Tag(
|
||||
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
|
||||
library.PosterURL,
|
||||
)
|
||||
|
||||
if first.ImageTags["Primary"] == "" {
|
||||
t.Fatal("library primary image tag is empty")
|
||||
}
|
||||
if first.ImageTags["Primary"] != second.ImageTags["Primary"] {
|
||||
t.Fatalf("library tag changed when only signed URL changed: %q vs %q", first.ImageTags["Primary"], second.ImageTags["Primary"])
|
||||
}
|
||||
if second.ImageTags["Primary"] != expected {
|
||||
t.Fatalf("library tag = %q, want %q", second.ImageTags["Primary"], expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySeriesImagesUsesCanonicalSeriesSeeds(t *testing.T) {
|
||||
secret := "image-secret"
|
||||
codec := NewResourceIDCodec()
|
||||
seriesContentID := "series-1"
|
||||
seriesRouteID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
|
||||
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
dto := baseItemDTO{SeriesID: seriesRouteID}
|
||||
series := seriesImageSet{
|
||||
ContentID: seriesContentID,
|
||||
PosterURL: "https://cdn.example.test/poster.jpg?sig=one",
|
||||
PosterPath: "metadb://poster/series-1",
|
||||
PosterThumbhash: "poster-thumbhash",
|
||||
BackdropURL: "https://cdn.example.test/backdrop.jpg?sig=one",
|
||||
BackdropPath: "metadb://backdrop/series-1",
|
||||
BackdropThumbhash: "backdrop-thumbhash",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
newMapper(codec, &config.Config{
|
||||
Auth: config.AuthConfig{JWTSecret: secret},
|
||||
}).applySeriesImages(&dto, series)
|
||||
|
||||
signer := newImageTagSigner(secret)
|
||||
expectedPrimary := signer.Tag(
|
||||
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, updatedAt),
|
||||
series.PosterURL,
|
||||
)
|
||||
expectedBackdrop := signer.Tag(
|
||||
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, updatedAt),
|
||||
series.BackdropURL,
|
||||
)
|
||||
|
||||
if dto.SeriesPrimaryImageTag != expectedPrimary {
|
||||
t.Fatalf("SeriesPrimaryImageTag = %q, want %q", dto.SeriesPrimaryImageTag, expectedPrimary)
|
||||
}
|
||||
if len(dto.ParentBackdropImageTags) != 1 || dto.ParentBackdropImageTags[0] != expectedBackdrop {
|
||||
t.Fatalf("ParentBackdropImageTags = %#v, want [%q]", dto.ParentBackdropImageTags, expectedBackdrop)
|
||||
}
|
||||
if dto.ParentThumbImageTag != expectedBackdrop {
|
||||
t.Fatalf("ParentThumbImageTag = %q, want %q", dto.ParentThumbImageTag, expectedBackdrop)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -73,6 +73,16 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
itemsHandler := NewItemsHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.ImageCache, nextUpRepo, deps.BrowseRepo, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.EpisodeRepo, deps.AccessFilterFn, subtitleRepo)
|
||||
itemsHandler.recommender = deps.Recommender
|
||||
autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler)
|
||||
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader)
|
||||
autoscanVirtualFoldersRegistered := false
|
||||
if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil {
|
||||
r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)).
|
||||
Get("/Library/VirtualFolders", autoscanHandler.HandleVirtualFolders)
|
||||
r.With(adminAPIKeyAuth.RequireAdminAPIKey).
|
||||
Post("/Library/Media/Updated", autoscanHandler.HandleMediaUpdated)
|
||||
autoscanVirtualFoldersRegistered = true
|
||||
}
|
||||
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
|
||||
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
|
||||
if deps.DB != nil {
|
||||
@@ -88,7 +98,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.S3Client = deps.S3Client
|
||||
playbackHandler.S3Bucket = deps.S3Bucket
|
||||
}
|
||||
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn)
|
||||
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret)
|
||||
displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider)
|
||||
recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn)
|
||||
|
||||
@@ -120,7 +130,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/Users/{id}", authHandler.HandleUserByID)
|
||||
r.Get("/UserViews", itemsHandler.HandleViews)
|
||||
r.Get("/UserViews/GroupingOptions", itemsHandler.HandleGroupingOptionsStub)
|
||||
r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders)
|
||||
if !autoscanVirtualFoldersRegistered {
|
||||
r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders)
|
||||
}
|
||||
r.Get("/Users/{userId}/Views", itemsHandler.HandleViews)
|
||||
r.Get("/Items", itemsHandler.HandleItems)
|
||||
r.Get("/Users/{id}/Items", itemsHandler.HandleItems)
|
||||
@@ -216,6 +228,9 @@ func withDefaults(deps Dependencies) Dependencies {
|
||||
if deps.Now == nil {
|
||||
deps.Now = timeNow
|
||||
}
|
||||
if deps.JWTSecret == "" && deps.Config != nil {
|
||||
deps.JWTSecret = deps.Config.Auth.JWTSecret
|
||||
}
|
||||
if deps.TokenGenerator == nil {
|
||||
deps.TokenGenerator = uuidNewString
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -41,6 +42,11 @@ type Dependencies struct {
|
||||
UserDataService UserDataService
|
||||
AuthService *auth.Service
|
||||
|
||||
// Autoscan / admin compatibility support.
|
||||
APIKeyValidator apiKeyValidator
|
||||
APIKeyUserLoader apiKeyUserLoader
|
||||
ScanQueue scantrigger.Queuer
|
||||
|
||||
// Catalog repos (for ContentService construction)
|
||||
BrowseRepo *catalog.BrowseRepository
|
||||
ItemRepo *catalog.ItemRepository
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
// catalog/service layer and the Jellyfin DTO mapping layer.
|
||||
|
||||
type upstreamUserLibrary struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
PosterPath string `json:"-"`
|
||||
}
|
||||
|
||||
type upstreamListItem struct {
|
||||
@@ -36,6 +37,7 @@ type upstreamListItem struct {
|
||||
BackdropThumbhash string `json:"-"`
|
||||
LogoPath string `json:"-"`
|
||||
StillPath string `json:"-"`
|
||||
StillThumbhash string `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
SeasonCount *int `json:"season_count,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
|
||||
@@ -403,7 +403,7 @@ func scopeMatchPaths(folder *models.MediaFolder, mode scopeMode, scopePath strin
|
||||
}
|
||||
|
||||
func shouldWaitForTVQueueSettle(folder *models.MediaFolder, scanResult *scanner.ScanResult) bool {
|
||||
if folder == nil || !isTVLibraryType(folder.Type) {
|
||||
if folder == nil || (!isTVLibraryType(folder.Type) && !isMixedLibraryType(folder.Type)) {
|
||||
return false
|
||||
}
|
||||
if scanResult == nil {
|
||||
@@ -421,6 +421,10 @@ func isTVLibraryType(libraryType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isMixedLibraryType(libraryType string) bool {
|
||||
return strings.ToLower(strings.TrimSpace(libraryType)) == "mixed"
|
||||
}
|
||||
|
||||
func (e *Executor) scan(ctx context.Context, folder *models.MediaFolder, mode scopeMode, scopePath string) ([]string, *scanner.ScanResult, error) {
|
||||
switch mode {
|
||||
case scopeModeLibrary:
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -23,6 +24,7 @@ type MatchCandidate struct {
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
AgreementHints []string `json:"agreement_hints"`
|
||||
DetailScore int `json:"-"`
|
||||
}
|
||||
|
||||
var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"}
|
||||
@@ -53,6 +55,106 @@ func providerIDRichness(ids map[string]string) int {
|
||||
return score
|
||||
}
|
||||
|
||||
const (
|
||||
minimumDetailTieBreakScore = 20
|
||||
minimumDetailTieBreakGap = 12
|
||||
)
|
||||
|
||||
func duplicateTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
|
||||
if hints == nil || len(scoredCandidates) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
best := scoredCandidates[0]
|
||||
contenders := []scoredMatchCandidate{best}
|
||||
for i := 1; i < len(scoredCandidates); i++ {
|
||||
next := scoredCandidates[i]
|
||||
if best.score-next.score >= 15 {
|
||||
break
|
||||
}
|
||||
if duplicateTieBreakComparable(hints, best.candidate, next.candidate) {
|
||||
contenders = append(contenders, next)
|
||||
}
|
||||
}
|
||||
if len(contenders) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sort.SliceStable(contenders, func(i, j int) bool {
|
||||
return contenders[i].candidate.DetailScore > contenders[j].candidate.DetailScore
|
||||
})
|
||||
if contenders[0].candidate.DetailScore < minimumDetailTieBreakScore {
|
||||
return nil, false
|
||||
}
|
||||
if contenders[0].candidate.DetailScore-contenders[1].candidate.DetailScore < minimumDetailTieBreakGap {
|
||||
return nil, false
|
||||
}
|
||||
return &contenders[0].candidate, true
|
||||
}
|
||||
|
||||
func duplicateTieBreakComparable(hints *MatchHints, left, right MatchCandidate) bool {
|
||||
if hints == nil {
|
||||
return false
|
||||
}
|
||||
if hints.Year == 0 || left.Year == 0 || right.Year == 0 {
|
||||
return false
|
||||
}
|
||||
if left.Year != hints.Year || right.Year != hints.Year || left.Year != right.Year {
|
||||
return false
|
||||
}
|
||||
if !candidateTypeMatchesHint(hints.Type, left.ContentType) ||
|
||||
!candidateTypeMatchesHint(hints.Type, right.ContentType) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.ContentType) != "" &&
|
||||
strings.TrimSpace(right.ContentType) != "" &&
|
||||
!strings.EqualFold(left.ContentType, right.ContentType) {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(left.Title, right.Title, hints.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(hints.Title, left.Title, hints.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(hints.Title, right.Title, hints.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
return samePrimaryProvider(left.ProviderIDs, right.ProviderIDs)
|
||||
}
|
||||
|
||||
func candidateTypeMatchesHint(hintType, candidateType string) bool {
|
||||
hintType = strings.ToLower(strings.TrimSpace(hintType))
|
||||
candidateType = strings.ToLower(strings.TrimSpace(candidateType))
|
||||
if hintType == "" || candidateType == "" {
|
||||
return true
|
||||
}
|
||||
if hintType == candidateType {
|
||||
return true
|
||||
}
|
||||
return isMovieTypeAlias(hintType) && isMovieTypeAlias(candidateType)
|
||||
}
|
||||
|
||||
func isMovieTypeAlias(value string) bool {
|
||||
switch value {
|
||||
case "movie", "movies":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func samePrimaryProvider(left, right map[string]string) bool {
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
leftValue := strings.TrimSpace(left[key])
|
||||
rightValue := strings.TrimSpace(right[key])
|
||||
if leftValue != "" && rightValue != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizedKey returns a stable grouping key from provider IDs.
|
||||
// Results with identical provider ID fingerprints (the exact set of
|
||||
// tmdb/tvdb/imdb key=value pairs) are considered the same candidate.
|
||||
@@ -211,13 +313,12 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
|
||||
|
||||
score += float64(len(candidate.Sources) * 12)
|
||||
|
||||
hintTitle := normalizeCandidateTitle(hints.Title)
|
||||
candidateTitle := normalizeCandidateTitle(candidate.Title)
|
||||
if hintTitle != "" && candidateTitle != "" {
|
||||
if hintTitle == candidateTitle {
|
||||
if strings.TrimSpace(hints.Title) != "" && strings.TrimSpace(candidate.Title) != "" {
|
||||
titleSimilarity := inferTitleSimilarity(hints.Title, candidate.Title, hints.Year)
|
||||
if titleSimilarity == 1 {
|
||||
score += 45
|
||||
} else {
|
||||
score += inferTitleSimilarity(hints.Title, candidate.Title) * 35
|
||||
score += titleSimilarity * 35
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,18 +337,19 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
|
||||
return score
|
||||
}
|
||||
|
||||
type scoredMatchCandidate struct {
|
||||
candidate MatchCandidate
|
||||
score float64
|
||||
}
|
||||
|
||||
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
candidate MatchCandidate
|
||||
score float64
|
||||
}
|
||||
scoredCandidates := make([]scored, 0, len(candidates))
|
||||
scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
scoredCandidates = append(scoredCandidates, scored{
|
||||
scoredCandidates = append(scoredCandidates, scoredMatchCandidate{
|
||||
candidate: candidate,
|
||||
score: scoreMatchCandidate(hints, candidate),
|
||||
})
|
||||
@@ -274,11 +376,76 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
|
||||
return &best.candidate, true
|
||||
}
|
||||
if best.score-scoredCandidates[1].score < 15 {
|
||||
return nil, false
|
||||
if winner, ok := duplicateTieBreakWinner(hints, scoredCandidates); ok {
|
||||
return winner, true
|
||||
}
|
||||
return providerOrderExactTieBreakWinner(hints, scoredCandidates)
|
||||
}
|
||||
return &best.candidate, true
|
||||
}
|
||||
|
||||
func providerOrderExactTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
|
||||
if hints == nil || len(scoredCandidates) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
best := scoredCandidates[0]
|
||||
contenders := []scoredMatchCandidate{best}
|
||||
for i := 1; i < len(scoredCandidates); i++ {
|
||||
next := scoredCandidates[i]
|
||||
if best.score-next.score >= 15 {
|
||||
break
|
||||
}
|
||||
contenders = append(contenders, next)
|
||||
}
|
||||
if len(contenders) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seenPrimaryProviders := make(map[string]struct{}, len(contenders))
|
||||
for _, contender := range contenders {
|
||||
if !exactTitleYearTypeMatch(hints, contender.candidate) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
primaryProvider := candidatePrimaryProvider(contender.candidate)
|
||||
if primaryProvider == "" {
|
||||
return nil, false
|
||||
}
|
||||
if _, exists := seenPrimaryProviders[primaryProvider]; exists {
|
||||
return nil, false
|
||||
}
|
||||
seenPrimaryProviders[primaryProvider] = struct{}{}
|
||||
}
|
||||
|
||||
return &best.candidate, true
|
||||
}
|
||||
|
||||
func exactTitleYearTypeMatch(hints *MatchHints, candidate MatchCandidate) bool {
|
||||
if hints == nil || hints.Year == 0 || candidate.Year == 0 {
|
||||
return false
|
||||
}
|
||||
if candidate.Year != hints.Year {
|
||||
return false
|
||||
}
|
||||
if !candidateTypeMatchesHint(hints.Type, candidate.ContentType) {
|
||||
return false
|
||||
}
|
||||
return inferTitleSimilarity(hints.Title, candidate.Title, hints.Year) == 1
|
||||
}
|
||||
|
||||
func candidatePrimaryProvider(candidate MatchCandidate) string {
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
if strings.TrimSpace(candidate.ProviderIDs[key]) != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
if len(candidate.Sources) == 1 {
|
||||
return strings.TrimSpace(candidate.Sources[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchCandidate) (*MatchCandidate, bool) {
|
||||
if existing == nil || len(candidates) == 0 {
|
||||
return nil, false
|
||||
@@ -345,17 +512,17 @@ func normalizeCandidateTitle(title string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
|
||||
}
|
||||
|
||||
func inferTitleSimilarity(left, right string) float64 {
|
||||
leftNorm := normalizeCandidateTitle(left)
|
||||
rightNorm := normalizeCandidateTitle(right)
|
||||
func inferTitleSimilarity(left, right string, year int) float64 {
|
||||
leftNorm := normalizeCandidateTitleForYear(left, year)
|
||||
rightNorm := normalizeCandidateTitleForYear(right, year)
|
||||
if leftNorm == "" || rightNorm == "" {
|
||||
return 0
|
||||
}
|
||||
if leftNorm == rightNorm {
|
||||
return 1
|
||||
}
|
||||
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(left)), " ")
|
||||
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(right)), " ")
|
||||
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(leftNorm)), " ")
|
||||
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(rightNorm)), " ")
|
||||
if leftComparable == rightComparable {
|
||||
return 1
|
||||
}
|
||||
@@ -365,6 +532,19 @@ func inferTitleSimilarity(left, right string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeCandidateTitleForYear(title string, year int) string {
|
||||
normalized := normalizeCandidateTitle(title)
|
||||
if normalized == "" || year == 0 {
|
||||
return normalized
|
||||
}
|
||||
yearText := strconv.Itoa(year)
|
||||
fields := strings.Fields(normalized)
|
||||
if len(fields) <= 1 || fields[len(fields)-1] != yearText {
|
||||
return normalized
|
||||
}
|
||||
return strings.Join(fields[:len(fields)-1], " ")
|
||||
}
|
||||
|
||||
func normalizeTitleForScoring(title string) string {
|
||||
title = naming.StripComparisonSafeEditionSuffix(title)
|
||||
title = strings.ToLower(strings.TrimSpace(title))
|
||||
|
||||
@@ -6,12 +6,43 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestTrustedIDValue_UsesMetadbContentID(t *testing.T) {
|
||||
func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hints := &MatchHints{ContentID: "meta-123"}
|
||||
if got := trustedIDValue(hints, "metadb"); got != "meta-123" {
|
||||
t.Fatalf("trustedIDValue(metadb) = %q, want meta-123", got)
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
ContentID: "local-skeleton-id",
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1217341"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected local content_id not to force trusted-ID matching")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := suppressTitleYearFallbackForTrustedIDs(SearchQuery{
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"metadb": "local-skeleton-id"},
|
||||
})
|
||||
|
||||
if query.Title != "AEW Worlds End" || query.Year != 2023 {
|
||||
t.Fatalf("title/year were suppressed for metadb: title=%q year=%d", query.Title, query.Year)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +388,255 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "AEW Worlds End 2023",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1217341"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
{
|
||||
Title: "AEW Worlds End 2023: Zero Hour",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1217342"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected provider title with repeated release year to be accepted")
|
||||
}
|
||||
if winner.Title != "AEW Worlds End 2023" {
|
||||
t.Fatalf("winner.Title = %q, want AEW Worlds End 2023", winner.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 18,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
|
||||
}
|
||||
if got := winner.ProviderIDs["tmdb"]; got != "17508" {
|
||||
t.Fatalf("winner tmdb = %q, want 17508", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 28,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 34,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
Type: "series",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tvdb": "383893"},
|
||||
Sources: []string{"tvdb"},
|
||||
},
|
||||
{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tmdb": "109792"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected exact cross-provider tie to use provider order")
|
||||
}
|
||||
if got := winner.ProviderIDs["tvdb"]; got != "383893" {
|
||||
t.Fatalf("winner tvdb = %q, want 383893", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
Type: "series",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tvdb": "383893"},
|
||||
Sources: []string{"tvdb"},
|
||||
},
|
||||
{
|
||||
Title: "Step Brothers",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tmdb": "109792", "imdb": "tt1234567"},
|
||||
Sources: []string{"imdb", "metadb", "tmdb", "xattr"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected non-equivalent cross-provider tie to remain unmatched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors Event",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 22,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4 Revenge of the Warriors Bonus",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "999999", "imdb": "tt9999999"},
|
||||
Sources: []string{"imdb", "tmdb"},
|
||||
DetailScore: 80,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected richer different-title candidate to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 18,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "series",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 18,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
|
||||
@@ -74,7 +74,10 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -98,7 +101,10 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = q.media_file_id
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -146,7 +152,10 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -170,7 +179,10 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = q.media_file_id
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -223,7 +235,10 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.file_path = $2 OR
|
||||
@@ -267,7 +282,10 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = q.media_file_id
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -301,7 +319,10 @@ func (r *MovieMatchQueueRepository) Claim(ctx context.Context, limit int) ([]*mo
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -369,7 +390,10 @@ func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
|
||||
WHERE q.media_folder_id = $1
|
||||
AND q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.file_path = $2 OR
|
||||
|
||||
@@ -115,7 +115,10 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context,
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -164,7 +167,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path IS NOT NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
@@ -191,7 +197,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -238,7 +247,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path IS NOT NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
@@ -296,7 +308,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -329,13 +344,17 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
|
||||
JOIN media_folders folders ON folders.id = q.media_folder_id
|
||||
WHERE q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
lower(trim(mf.base_type)) = 'series'
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -412,7 +431,7 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
|
||||
WHERE q.media_folder_id = $1
|
||||
AND q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
|
||||
AND (
|
||||
q.observed_root_path = $2 OR q.observed_root_path LIKE $3 ESCAPE '\' OR
|
||||
EXISTS (
|
||||
@@ -430,6 +449,10 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
lower(trim(mf.base_type)) = 'series'
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
|
||||
@@ -183,7 +183,7 @@ type metadataServiceHooks struct {
|
||||
ensureSeriesEpisodeLinks func(ctx context.Context, seriesID string) error
|
||||
}
|
||||
|
||||
var trustedSearchIDKeys = []string{"metadb", "tmdb", "tvdb", "imdb"}
|
||||
var trustedSearchIDKeys = []string{"tmdb", "tvdb", "imdb"}
|
||||
|
||||
var ErrMetadataNotFound = errors.New("no metadata found from any provider")
|
||||
|
||||
@@ -829,10 +829,8 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
|
||||
if req.Hints == nil {
|
||||
return nil, fmt.Errorf("initial match requires hints")
|
||||
}
|
||||
// Seed IDs from hints.
|
||||
if req.Hints.ContentID != "" {
|
||||
accumulatedIDs["metadb"] = req.Hints.ContentID
|
||||
}
|
||||
// Seed external IDs from hints. Hints.ContentID is Silo's local
|
||||
// skeleton item ID, not a searchable provider ID.
|
||||
if req.Hints.FileHash != "" {
|
||||
accumulatedIDs["oshash"] = req.Hints.FileHash
|
||||
}
|
||||
@@ -962,8 +960,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Run search providers to resolve the metadb ID from external IDs.
|
||||
// Without this, GetMetadata has no valid metadb ID to fetch from.
|
||||
// Run search providers to refresh provider IDs before fetching full metadata.
|
||||
searchQuery := SearchQuery{
|
||||
Title: existing.Title,
|
||||
Year: existing.Year,
|
||||
|
||||
+119
-51
@@ -364,7 +364,7 @@ func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err erro
|
||||
// ProcessBatchByFolderAndPathPrefix processes unmatched files within a single
|
||||
// library subtree immediately instead of waiting for the periodic worker loop.
|
||||
func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time) (processed int, err error) {
|
||||
useSeriesQueue, err := w.useSeriesGroupQueueForFolder(ctx, folderID)
|
||||
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -373,21 +373,29 @@ func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, fol
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.processSeriesRoots(ctx, jobs)
|
||||
processed, err := w.processSeriesRoots(ctx, jobs)
|
||||
if err != nil || processed > 0 {
|
||||
return processed, err
|
||||
}
|
||||
}
|
||||
useMovieQueue, err := w.useMovieQueueForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
if useSeriesQueue && !useMovieQueue {
|
||||
return processed, nil
|
||||
}
|
||||
if useMovieQueue {
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.processQueuedMovieFiles(ctx, files), nil
|
||||
processed := w.processQueuedMovieFiles(ctx, files)
|
||||
if processed > 0 {
|
||||
return processed, nil
|
||||
}
|
||||
if !useSeriesQueue {
|
||||
return processed, nil
|
||||
}
|
||||
}
|
||||
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, false)
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -400,11 +408,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
|
||||
if attemptBefore.IsZero() {
|
||||
attemptBefore = time.Now().UTC()
|
||||
}
|
||||
useSeriesQueue, err := w.useSeriesGroupQueueForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
useMovieQueue, err := w.useMovieQueueForFolder(ctx, folderID)
|
||||
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -424,30 +428,31 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return processed, nil
|
||||
if len(jobs) > 0 {
|
||||
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
if useMovieQueue {
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return processed, nil
|
||||
if len(files) > 0 {
|
||||
batchProcessed := w.processQueuedMovieFiles(ctx, files)
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
batchProcessed := w.processQueuedMovieFiles(ctx, files)
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
if useSeriesQueue != useMovieQueue {
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, w.enableTVSeriesRootQueue)
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
@@ -705,6 +710,8 @@ func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *mod
|
||||
|
||||
func scopedMatcherPath(useSeriesQueue bool, useMovieQueue bool) string {
|
||||
switch {
|
||||
case useSeriesQueue && useMovieQueue:
|
||||
return "series_root_queue+movie_file_queue"
|
||||
case useSeriesQueue:
|
||||
return "series_root_queue"
|
||||
case useMovieQueue:
|
||||
@@ -792,6 +799,43 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
|
||||
}
|
||||
if !hasUnlinkedGroupFile(groupFiles) {
|
||||
if strings.TrimSpace(representative.ContentID) != "" {
|
||||
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, representative); ok && skeleton.ItemStatus != "ambiguous" {
|
||||
req := w.buildProcessRequestForGroup(ctx, representative, skeleton, groupFiles)
|
||||
result, processErr := w.service.Process(ctx, req)
|
||||
if processErr != nil {
|
||||
queueErr := truncateSeriesQueueError(processErr.Error())
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
slog.Warn("metadata: enrichment failed",
|
||||
"file_id", representative.ID,
|
||||
"path", representative.FilePath,
|
||||
"error", processErr)
|
||||
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
||||
"content_id", skeleton.ContentID,
|
||||
"file_id", representative.ID,
|
||||
"path", representative.FilePath)
|
||||
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
||||
slog.Warn("metadata: fallback episode synthesis failed after series root enrichment error",
|
||||
"content_id", skeleton.ContentID, "error", fbErr)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if result != nil && !result.Updated {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
||||
"content_id", skeleton.ContentID,
|
||||
"file_id", representative.ID,
|
||||
"path", representative.FilePath)
|
||||
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
||||
slog.Warn("metadata: fallback episode synthesis failed for unmatched series root",
|
||||
"content_id", skeleton.ContentID, "error", fbErr)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
@@ -1016,31 +1060,16 @@ func (w *MatchWorker) folderType(ctx context.Context, folderID int) (string, err
|
||||
return strings.ToLower(strings.TrimSpace(folder.Type)), nil
|
||||
}
|
||||
|
||||
func (w *MatchWorker) useSeriesGroupQueueForFolder(ctx context.Context, folderID int) (bool, error) {
|
||||
if !w.enableTVSeriesRootQueue || w.seriesClaimer == nil {
|
||||
return false, nil
|
||||
}
|
||||
func (w *MatchWorker) queueUsageForFolder(ctx context.Context, folderID int) (useSeriesQueue bool, useMovieQueue bool, err error) {
|
||||
folderType, err := w.folderType(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return isTVLibraryType(folderType), nil
|
||||
}
|
||||
|
||||
func (w *MatchWorker) useMovieQueueForFolder(ctx context.Context, folderID int) (bool, error) {
|
||||
if w.movieClaimer == nil {
|
||||
return false, nil
|
||||
}
|
||||
folderType, err := w.folderType(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch folderType {
|
||||
case "movie", "movies":
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
return false, false, err
|
||||
}
|
||||
useSeriesQueue = w.enableTVSeriesRootQueue &&
|
||||
w.seriesClaimer != nil &&
|
||||
(isTVLibraryType(folderType) || isMixedLibraryType(folderType))
|
||||
useMovieQueue = w.movieClaimer != nil && isMovieLibraryType(folderType)
|
||||
return useSeriesQueue, useMovieQueue, nil
|
||||
}
|
||||
|
||||
func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.MediaFile, error) {
|
||||
@@ -1059,14 +1088,40 @@ func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.Media
|
||||
return w.fileLister.ClaimUnmatched(ctx, w.batchSize)
|
||||
}
|
||||
|
||||
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, preferNonSeries bool) ([]*models.MediaFile, error) {
|
||||
if preferNonSeries {
|
||||
type scopedFallbackClaimMode int
|
||||
|
||||
const (
|
||||
scopedFallbackGeneric scopedFallbackClaimMode = iota
|
||||
scopedFallbackNonSeries
|
||||
scopedFallbackMixed
|
||||
)
|
||||
|
||||
func scopedFallbackMode(useSeriesQueue bool, useMovieQueue bool) scopedFallbackClaimMode {
|
||||
switch {
|
||||
case useSeriesQueue && useMovieQueue:
|
||||
return scopedFallbackMixed
|
||||
case useSeriesQueue:
|
||||
return scopedFallbackNonSeries
|
||||
default:
|
||||
return scopedFallbackGeneric
|
||||
}
|
||||
}
|
||||
|
||||
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, mode scopedFallbackClaimMode) ([]*models.MediaFile, error) {
|
||||
switch mode {
|
||||
case scopedFallbackMixed:
|
||||
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedMixedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
return nil, fmt.Errorf("mixed-library file claimer is not configured")
|
||||
case scopedFallbackNonSeries:
|
||||
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
return nil, fmt.Errorf("non-series file claimer is not configured")
|
||||
default:
|
||||
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
|
||||
func selectRepresentativeGroupFile(groupFiles []*models.MediaFile) *models.MediaFile {
|
||||
@@ -1117,6 +1172,19 @@ func isTVLibraryType(folderType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isMixedLibraryType(folderType string) bool {
|
||||
return strings.ToLower(strings.TrimSpace(folderType)) == "mixed"
|
||||
}
|
||||
|
||||
func isMovieLibraryType(folderType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(folderType)) {
|
||||
case "movie", "movies", "mixed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RetryUnmatchedItemsByFolderAndPathPrefix revisits linked unmatched items in
|
||||
// scope once. Per-item retry failures are counted as warnings, not fatal.
|
||||
func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) (retried int, stillUnmatched int, err error) {
|
||||
|
||||
@@ -718,6 +718,74 @@ func TestWorkerProcessAllByFolderAndPathPrefix_MovieFolderUsesMovieQueue(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerProcessAllByFolderAndPathPrefix_MixedFolderDrainsSeriesAndMovieQueues(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
h.service.folderRepo = &fakeWorkerFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "mixed", Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
seriesFile := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/mixed/television/Example Show/Season 01/Example.Show.S01E01.mkv",
|
||||
ObservedRootPath: "/media/mixed/television/Example Show",
|
||||
GroupKeyVersion: 1,
|
||||
ContentGroupKey: "v1|series|example_show|2024",
|
||||
BaseTitle: "Example Show",
|
||||
BaseType: "series",
|
||||
}
|
||||
movieFile := &models.MediaFile{
|
||||
ID: 2,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/mixed/movies/Example Movie (2024)/Example.Movie.mkv",
|
||||
BaseTitle: "Example Movie",
|
||||
BaseType: "movie",
|
||||
}
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", seriesFile)
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|movie|example_movie|2024", movieFile)
|
||||
|
||||
processedTypes := make([]string, 0, 2)
|
||||
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
||||
processedTypes = append(processedTypes, req.Hints.Type)
|
||||
return &ProcessResult{Updated: true}, nil
|
||||
}
|
||||
|
||||
seriesQueueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
|
||||
MediaFolderID: 10,
|
||||
ObservedRootPath: "/media/mixed/television/Example Show",
|
||||
SampleFilePath: seriesFile.FilePath,
|
||||
ObservedFileCount: 1,
|
||||
})
|
||||
movieQueueRepo := newFakeMovieQueueRepo(movieFile)
|
||||
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetSeriesRootClaimer(seriesQueueRepo, true)
|
||||
worker.SetMovieFileClaimer(movieQueueRepo)
|
||||
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/mixed", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
|
||||
}
|
||||
|
||||
if processed != 2 {
|
||||
t.Fatalf("processed = %d, want 2", processed)
|
||||
}
|
||||
if got, want := strings.Join(processedTypes, ","), "series,movie"; got != want {
|
||||
t.Fatalf("processed types = %q, want %q", got, want)
|
||||
}
|
||||
if seriesQueueRepo.scopedClaimCalls == 0 {
|
||||
t.Fatal("expected series queue to be claimed")
|
||||
}
|
||||
if movieQueueRepo.scopedClaimCalls == 0 {
|
||||
t.Fatal("expected movie queue to be claimed")
|
||||
}
|
||||
if h.fileRepo.claimMixedCalls != 1 {
|
||||
t.Fatalf("claimMixedCalls = %d, want 1", h.fileRepo.claimMixedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerProcessAllByFolderAndPathPrefix_SeriesRootSkeletonErrorKeepsQueueRow(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
@@ -1000,7 +1068,7 @@ func TestWorkerClaimScopedFiles_WithMovieQueueAndTVQueueDisabledUsesScopedGeneri
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetMovieFileClaimer(newFakeMovieQueueRepo())
|
||||
|
||||
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, false)
|
||||
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, scopedFallbackGeneric)
|
||||
if err != nil {
|
||||
t.Fatalf("claimScopedFiles error = %v", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type User struct {
|
||||
PasswordHash string
|
||||
LocalPasswordLoginEnabled bool
|
||||
Role string
|
||||
Permissions []string
|
||||
Enabled bool
|
||||
LibraryIDs []int // nullable in PG (nil = all libraries)
|
||||
MaxPlaybackQuality string
|
||||
@@ -30,6 +31,7 @@ type CreateUserInput struct {
|
||||
Password string // plaintext, will be bcrypt-hashed
|
||||
LocalPasswordLoginEnabled *bool
|
||||
Role string // e.g. "admin", "user"
|
||||
Permissions []string
|
||||
LibraryIDs []int
|
||||
MaxPlaybackQuality string
|
||||
MaxStreams *int // nil = use DB default (6)
|
||||
@@ -47,6 +49,7 @@ type UpdateUserInput struct {
|
||||
Password *string // plaintext, will be bcrypt-hashed if provided
|
||||
LocalPasswordLoginEnabled *bool
|
||||
Role *string
|
||||
Permissions *[]string
|
||||
Enabled *bool
|
||||
LibraryIDs *[]int
|
||||
MaxPlaybackQuality *string
|
||||
|
||||
@@ -59,7 +59,7 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
|
||||
group.TvdbID = ids.TvdbID
|
||||
}
|
||||
if group.TmdbID == "" || group.ImdbID == "" || group.TvdbID == "" {
|
||||
if ids := ParseFolderIDs(strings.TrimSuffix(filepath.Base(cleanFilePath), filepath.Ext(cleanFilePath)), group.BaseType); ids != nil {
|
||||
if ids := ParseStructuredFolderIDs(strings.TrimSuffix(filepath.Base(cleanFilePath), filepath.Ext(cleanFilePath))); ids != nil {
|
||||
if group.TmdbID == "" {
|
||||
group.TmdbID = ids.TmdbID
|
||||
}
|
||||
|
||||
@@ -1207,7 +1207,6 @@ func ptrFloatEqual(a, b *float64) bool {
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
|
||||
func nextSharedMarkerAttribution(
|
||||
existingSource *string,
|
||||
existingConfidence *float64,
|
||||
@@ -1438,6 +1437,7 @@ func (r *FileRepository) ClaimUnmatchedMixed(ctx context.Context, limit int) ([]
|
||||
AND mf.missing_since IS NULL
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
@@ -1695,6 +1695,7 @@ func (r *FileRepository) ClaimUnmatchedMixedByFolderAndPathPrefix(
|
||||
AND mf.missing_since IS NULL
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\')
|
||||
`)
|
||||
if !attemptBefore.IsZero() {
|
||||
|
||||
@@ -123,6 +123,28 @@ func TestInferGroupAssignments_SeriesSeasonDirsCollapse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferGroupAssignments_SeriesEpisodeTitleNumberDoesNotBecomeTVDBID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePaths := []string{
|
||||
"/sports/television/WWE SmackDown (1999)/Season 01/WWE SmackDown (1999) - S01E01 - SmackDown 01.mkv",
|
||||
}
|
||||
|
||||
rootInference := inferRootAssignments(filePaths, "mixed", 7, nil)
|
||||
groupInference := inferGroupAssignments(filePaths, "mixed", 7, rootInference.Assignments)
|
||||
|
||||
if got, want := len(groupInference.ScannedGroups), 1; got != want {
|
||||
t.Fatalf("len(ScannedGroups) = %d, want %d", got, want)
|
||||
}
|
||||
group := groupInference.ScannedGroups[0]
|
||||
if got, want := group.InferredType, "series"; got != want {
|
||||
t.Fatalf("InferredType = %q, want %q", got, want)
|
||||
}
|
||||
if group.TvdbID != "" {
|
||||
t.Fatalf("TvdbID = %q, want empty", group.TvdbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferGroupAssignments_MovieEpisodeTokenFolderStaysResolved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -121,6 +121,68 @@ func (r *Repository) Create(ctx context.Context, input CreateInput) (*models.Sca
|
||||
return nil, false, fmt.Errorf("create scan run: %w", err)
|
||||
}
|
||||
|
||||
func (r *Repository) CreateBatch(ctx context.Context, inputs []CreateInput) ([]*models.ScanRun, []bool, error) {
|
||||
if len(inputs) == 0 {
|
||||
return []*models.ScanRun{}, []bool{}, nil
|
||||
}
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("begin scan run batch: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
runs := make([]*models.ScanRun, 0, len(inputs))
|
||||
created := make([]bool, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
run, err := scanRunRow(tx.QueryRow(ctx, `
|
||||
INSERT INTO scan_runs (
|
||||
id, media_folder_id, mode, path, trigger, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING `+scanRunColumns,
|
||||
ulid.Make().String(),
|
||||
input.LibraryID,
|
||||
input.Mode,
|
||||
input.Path,
|
||||
input.Trigger,
|
||||
StatusAccepted,
|
||||
))
|
||||
if err == nil {
|
||||
runs = append(runs, run)
|
||||
created = append(created, true)
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, ErrScanRunNotFound) {
|
||||
return nil, nil, fmt.Errorf("create scan run: %w", err)
|
||||
}
|
||||
|
||||
existing, lookupErr := scanRunRow(tx.QueryRow(ctx, `
|
||||
SELECT `+scanRunColumns+`
|
||||
FROM scan_runs
|
||||
WHERE media_folder_id = $1
|
||||
AND mode = $2
|
||||
AND path = $3
|
||||
AND status = ANY($4)
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1`,
|
||||
input.LibraryID,
|
||||
input.Mode,
|
||||
input.Path,
|
||||
[]string{StatusAccepted, StatusRunning},
|
||||
))
|
||||
if lookupErr != nil {
|
||||
return nil, nil, lookupErr
|
||||
}
|
||||
runs = append(runs, existing)
|
||||
created = append(created, false)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("commit scan run batch: %w", err)
|
||||
}
|
||||
return runs, created, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveByScope(ctx context.Context, libraryID int, mode, path string) (*models.ScanRun, error) {
|
||||
return scanRunRow(r.pool.QueryRow(ctx, `
|
||||
SELECT `+scanRunColumns+`
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/libraryingest"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -131,6 +132,34 @@ func (s *Service) EnqueueScan(ctx context.Context, folderID int, mode, path, tri
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target) error {
|
||||
if s == nil || s.repo == nil {
|
||||
return fmt.Errorf("scan queue is not configured")
|
||||
}
|
||||
inputs := make([]CreateInput, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Folder == nil {
|
||||
return fmt.Errorf("scan queue: target is missing folder")
|
||||
}
|
||||
inputs = append(inputs, CreateInput{
|
||||
LibraryID: target.Folder.ID,
|
||||
Mode: target.Mode,
|
||||
Path: target.Path,
|
||||
Trigger: target.Trigger,
|
||||
})
|
||||
}
|
||||
runs, created, err := s.repo.CreateBatch(ctx, inputs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, run := range runs {
|
||||
if i < len(created) && created[i] {
|
||||
s.publish(ctx, "scan.accepted", run)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, nil
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package scantrigger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
const (
|
||||
ModeLibrary = "library"
|
||||
ModeSubtree = "subtree"
|
||||
ModeFile = "file"
|
||||
)
|
||||
|
||||
type FolderRepository interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
|
||||
List(ctx context.Context) ([]*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
type Queuer interface {
|
||||
EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error)
|
||||
EnqueueScans(ctx context.Context, targets []Target) error
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
LibraryID *int
|
||||
Path string
|
||||
Trigger string
|
||||
}
|
||||
|
||||
// Target is a fully-resolved scan request. Folder is always non-nil for
|
||||
// targets returned by Resolver; callers should read the library ID via
|
||||
// target.Folder.ID rather than tracking it separately.
|
||||
type Target struct {
|
||||
Folder *models.MediaFolder
|
||||
Mode string
|
||||
Path string
|
||||
Trigger string
|
||||
}
|
||||
|
||||
type RequestError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *RequestError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
folders FolderRepository
|
||||
}
|
||||
|
||||
func NewResolver(folders FolderRepository) *Resolver {
|
||||
return &Resolver{folders: folders}
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) {
|
||||
targets := make([]Target, 0, len(requests))
|
||||
var pathFolders []*models.MediaFolder
|
||||
pathFoldersLoaded := false
|
||||
for _, req := range requests {
|
||||
usePathFolders := req.LibraryID == nil && strings.TrimSpace(req.Path) != ""
|
||||
if usePathFolders && !pathFoldersLoaded {
|
||||
if r == nil || r.folders == nil {
|
||||
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
|
||||
}
|
||||
folders, listErr := r.folders.List(ctx)
|
||||
if listErr != nil {
|
||||
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
|
||||
}
|
||||
pathFolders = folders
|
||||
pathFoldersLoaded = true
|
||||
}
|
||||
|
||||
target, err := r.resolve(ctx, req, pathFolders, usePathFolders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets = append(targets, *target)
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) {
|
||||
return r.resolve(ctx, req, nil, false)
|
||||
}
|
||||
|
||||
func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*models.MediaFolder, usePathFolders bool) (*Target, error) {
|
||||
if r == nil || r.folders == nil {
|
||||
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
|
||||
}
|
||||
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Either library_id or path is required"}
|
||||
}
|
||||
|
||||
var folder *models.MediaFolder
|
||||
var err error
|
||||
if req.LibraryID != nil {
|
||||
folder, err = r.folders.GetByID(ctx, *req.LibraryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrFolderNotFound) {
|
||||
return nil, &RequestError{Status: http.StatusNotFound, Code: "not_found", Message: "Library not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("fetching library for scan: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
trigger := strings.TrimSpace(req.Trigger)
|
||||
if trigger == "" {
|
||||
trigger = "manual"
|
||||
}
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
|
||||
}
|
||||
return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(req.Path)
|
||||
var matchedRoot string
|
||||
if folder != nil {
|
||||
matchedRoot, err = LongestMatchingRoot(cleanPath, folder.Paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if matchedRoot == "" {
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"}
|
||||
}
|
||||
} else {
|
||||
folders := pathFolders
|
||||
if !usePathFolders {
|
||||
var listErr error
|
||||
folders, listErr = r.folders.List(ctx)
|
||||
if listErr != nil {
|
||||
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
|
||||
}
|
||||
}
|
||||
folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
|
||||
}
|
||||
|
||||
mode, err := ClassifyPath(cleanPath, matchedRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if trigger == "manual" {
|
||||
trigger = "path"
|
||||
if req.LibraryID != nil {
|
||||
trigger = "library_id_path"
|
||||
}
|
||||
}
|
||||
|
||||
targetPath := cleanPath
|
||||
if mode == ModeLibrary {
|
||||
targetPath = ""
|
||||
}
|
||||
return &Target{Folder: folder, Mode: mode, Path: targetPath, Trigger: trigger}, nil
|
||||
}
|
||||
|
||||
func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error {
|
||||
if queue == nil {
|
||||
return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
|
||||
}
|
||||
if err := queue.EnqueueScans(ctx, targets); err != nil {
|
||||
return fmt.Errorf("queueing library scans: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LongestMatchingRoot(targetPath string, roots []string) (string, error) {
|
||||
bestRoot := ""
|
||||
bestLen := -1
|
||||
for _, root := range roots {
|
||||
if !PathWithinRoot(targetPath, root) {
|
||||
continue
|
||||
}
|
||||
cleanRoot := filepath.Clean(root)
|
||||
rootLen := len(cleanRoot)
|
||||
if rootLen > bestLen {
|
||||
bestRoot = cleanRoot
|
||||
bestLen = rootLen
|
||||
}
|
||||
}
|
||||
return bestRoot, nil
|
||||
}
|
||||
|
||||
func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
|
||||
var bestFolder *models.MediaFolder
|
||||
bestRoot := ""
|
||||
bestLen := -1
|
||||
ambiguous := false
|
||||
|
||||
for _, folder := range folders {
|
||||
if folder == nil {
|
||||
continue
|
||||
}
|
||||
root, err := LongestMatchingRoot(targetPath, folder.Paths)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
rootLen := len(root)
|
||||
if rootLen > bestLen {
|
||||
bestFolder = folder
|
||||
bestRoot = root
|
||||
bestLen = rootLen
|
||||
ambiguous = false
|
||||
continue
|
||||
}
|
||||
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
if ambiguous {
|
||||
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path matches multiple libraries"}
|
||||
}
|
||||
if bestFolder == nil {
|
||||
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "No library matches the given path"}
|
||||
}
|
||||
return bestFolder, bestRoot, nil
|
||||
}
|
||||
|
||||
func ClassifyPath(targetPath, matchedRoot string) (string, error) {
|
||||
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
|
||||
return ModeLibrary, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not exist"}
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Permission denied for path"}
|
||||
default:
|
||||
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"}
|
||||
}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return ModeSubtree, nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"}
|
||||
}
|
||||
if !scanner.SupportsVideoFile(targetPath) {
|
||||
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"}
|
||||
}
|
||||
return ModeFile, nil
|
||||
}
|
||||
|
||||
func PathWithinRoot(targetPath, rootPath string) bool {
|
||||
cleanTarget := filepath.Clean(targetPath)
|
||||
cleanRoot := filepath.Clean(rootPath)
|
||||
rel, err := filepath.Rel(cleanRoot, cleanTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if rel == "." || rel == "" {
|
||||
return true
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package scantrigger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
folders []*models.MediaFolder
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
|
||||
for _, folder := range r.folders {
|
||||
if folder.ID == id {
|
||||
return folder, nil
|
||||
}
|
||||
}
|
||||
return nil, catalog.ErrFolderNotFound
|
||||
}
|
||||
|
||||
func (r *fakeFolderRepo) List(context.Context) ([]*models.MediaFolder, error) {
|
||||
r.listCalls++
|
||||
return r.folders, nil
|
||||
}
|
||||
|
||||
func TestResolverClassifiesLibraryRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 7,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if target.Folder == nil || target.Folder.ID != 7 || target.Mode != ModeLibrary || target.Path != "" {
|
||||
t.Fatalf("unexpected target: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverClassifiesSubtree(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
subtree := filepath.Join(root, "Show")
|
||||
if err := os.Mkdir(subtree, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 8,
|
||||
Name: "TV",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: subtree})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if target.Folder == nil || target.Folder.ID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) {
|
||||
t.Fatalf("unexpected target: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverClassifiesVideoFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie (2024).mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 9,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if target.Folder == nil || target.Folder.ID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) {
|
||||
t.Fatalf("unexpected target: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsDisabledLibrary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 10,
|
||||
Name: "Disabled",
|
||||
Enabled: false,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
_, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root})
|
||||
var reqErr *RequestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
t.Fatalf("expected RequestError, got %T: %v", err, err)
|
||||
}
|
||||
if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" {
|
||||
t.Fatalf("unexpected error: %#v", reqErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllIsAllOrFail(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
valid := filepath.Join(root, "Movie.mkv")
|
||||
if err := os.WriteFile(valid, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 11,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
_, err := NewResolver(repo).ResolveAll(context.Background(), []Request{
|
||||
{Path: valid},
|
||||
{Path: filepath.Join(root, "missing.mkv")},
|
||||
})
|
||||
var reqErr *RequestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
t.Fatalf("expected RequestError, got %T: %v", err, err)
|
||||
}
|
||||
if reqErr.Message != "Path does not exist" {
|
||||
t.Fatalf("unexpected error message: %q", reqErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllReusesPathOnlyLibraryList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "First.mkv")
|
||||
second := filepath.Join(root, "Second.mkv")
|
||||
for _, path := range []string{first, second} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 12,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
targets, err := NewResolver(repo).ResolveAll(context.Background(), []Request{
|
||||
{Path: first},
|
||||
{Path: second},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAll returned error: %v", err)
|
||||
}
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("expected two targets, got %d", len(targets))
|
||||
}
|
||||
if repo.listCalls != 1 {
|
||||
t.Fatalf("expected one folder list lookup, got %d", repo.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeQueue struct {
|
||||
calls []Target
|
||||
batches [][]Target
|
||||
batchErr error
|
||||
}
|
||||
|
||||
func (q *fakeQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) {
|
||||
q.calls = append(q.calls, Target{Folder: &models.MediaFolder{ID: folderID}, Mode: mode, Path: path, Trigger: trigger})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (q *fakeQueue) EnqueueScans(_ context.Context, targets []Target) error {
|
||||
copied := append([]Target(nil), targets...)
|
||||
q.batches = append(q.batches, copied)
|
||||
if q.batchErr != nil {
|
||||
return q.batchErr
|
||||
}
|
||||
q.calls = append(q.calls, targets...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEnqueueAllUsesBatchQueue(t *testing.T) {
|
||||
queue := &fakeQueue{}
|
||||
folder := &models.MediaFolder{ID: 1}
|
||||
targets := []Target{
|
||||
{Folder: folder, Mode: ModeFile, Path: "/media/one.mkv", Trigger: "autoscan"},
|
||||
{Folder: folder, Mode: ModeFile, Path: "/media/two.mkv", Trigger: "autoscan"},
|
||||
}
|
||||
|
||||
if err := EnqueueAll(context.Background(), queue, targets); err != nil {
|
||||
t.Fatalf("EnqueueAll returned error: %v", err)
|
||||
}
|
||||
if len(queue.batches) != 1 {
|
||||
t.Fatalf("expected one batch enqueue, got %d", len(queue.batches))
|
||||
}
|
||||
if len(queue.calls) != 2 {
|
||||
t.Fatalf("expected two queued calls from batch, got %d", len(queue.calls))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func (m *TaskManager) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
w.setTriggers(configs, m.triggerFactory, w.lastResult)
|
||||
w.setTriggers(configs, m.triggerFactory, w.lastResult, false)
|
||||
|
||||
go m.triggerLoop(ctx, w)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func (m *TaskManager) UpdateTriggers(key string, triggerConfigs []TriggerConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
w.setTriggers(triggerConfigs, m.triggerFactory, nil)
|
||||
w.setTriggers(triggerConfigs, m.triggerFactory, nil, true)
|
||||
m.notifyTaskUpdated(w.info())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func (w *taskWorker) info() TaskInfo {
|
||||
// setTriggers replaces active triggers. Stops old triggers, starts new ones.
|
||||
// Pass lastResult to resume scheduling from the last execution, or nil to
|
||||
// start the interval fresh from now (e.g. when the user edits the schedule).
|
||||
func (w *taskWorker) setTriggers(configs []TriggerConfig, factory func(TriggerConfig) Trigger, lastResult *ExecutionResult) {
|
||||
func (w *taskWorker) setTriggers(configs []TriggerConfig, factory func(TriggerConfig) Trigger, lastResult *ExecutionResult, notify bool) {
|
||||
w.mu.Lock()
|
||||
|
||||
for _, tr := range w.triggers {
|
||||
@@ -90,8 +90,12 @@ func (w *taskWorker) setTriggers(configs []TriggerConfig, factory func(TriggerCo
|
||||
|
||||
w.mu.Unlock()
|
||||
|
||||
w.triggerChanged.Store(true)
|
||||
if !notify {
|
||||
w.notify()
|
||||
return
|
||||
}
|
||||
|
||||
w.triggerChanged.Store(true)
|
||||
select {
|
||||
case w.triggerUpdate <- struct{}{}:
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package taskmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testTask struct{}
|
||||
|
||||
func (testTask) Key() string { return "test" }
|
||||
func (testTask) Name() string { return "test" }
|
||||
func (testTask) Description() string { return "test" }
|
||||
func (testTask) Category() TaskCategory { return TaskCategorySystem }
|
||||
func (testTask) IsHidden() bool { return false }
|
||||
func (testTask) DefaultTriggers() []TriggerConfig { return nil }
|
||||
func (testTask) Execute(context.Context, ProgressReporter) error { return nil }
|
||||
|
||||
type testTrigger struct {
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
func newTestTrigger(TriggerConfig) Trigger {
|
||||
return &testTrigger{ch: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (t *testTrigger) Start(*ExecutionResult) {
|
||||
t.ch <- struct{}{}
|
||||
}
|
||||
|
||||
func (t *testTrigger) Stop() {}
|
||||
func (t *testTrigger) NextRunTime() time.Time { return time.Time{} }
|
||||
func (t *testTrigger) Config() TriggerConfig { return TriggerConfig{} }
|
||||
func (t *testTrigger) C() <-chan struct{} { return t.ch }
|
||||
|
||||
func TestInitialSetTriggersDoesNotMarkTriggerChanged(t *testing.T) {
|
||||
worker := newTaskWorker(testTask{}, nil)
|
||||
worker.setTriggers([]TriggerConfig{{Type: TriggerTypeInterval, IntervalMs: 1}}, newTestTrigger, nil, false)
|
||||
|
||||
if worker.triggerChanged.Load() {
|
||||
t.Fatal("initial trigger setup should not mark triggers changed")
|
||||
}
|
||||
select {
|
||||
case <-worker.triggerUpdate:
|
||||
t.Fatal("initial trigger setup should not queue a trigger update")
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -255,18 +255,16 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
|
||||
slog.Warn("webhook sync: failed to upsert seen external user", "connection_id", conn.ID, "external_user_id", event.UserID, "error", err)
|
||||
}
|
||||
|
||||
profileID := conn.DefaultProfileID
|
||||
if mapping, err := s.repo.GetMappingByUser(ctx, conn.ID, event.UserID); err != nil {
|
||||
return s.failWebhook(ctx, conn.ID, result, err, "Failed to resolve profile mapping")
|
||||
} else if mapping != nil && mapping.SiloProfileID != nil && *mapping.SiloProfileID != "" {
|
||||
profileID = *mapping.SiloProfileID
|
||||
}
|
||||
result.ProfileID = profileID
|
||||
if profileID == "" {
|
||||
} else if profileID, ok := resolveWebhookProfileID(mapping); ok {
|
||||
result.ProfileID = profileID
|
||||
} else {
|
||||
result.Outcome = OutcomeSkipped
|
||||
result.Summary = "Skipped because no default or user-specific profile is configured"
|
||||
result.Summary = "Skipped because external user is not linked to a Silo profile"
|
||||
return result, nil
|
||||
}
|
||||
profileID := result.ProfileID
|
||||
|
||||
record := event.Record.toHistoryImportRecord()
|
||||
match, _, err := s.matcher.Match(ctx, record)
|
||||
@@ -432,6 +430,13 @@ func shouldSkipEvent(state *ItemState, event *CanonicalEvent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveWebhookProfileID(mapping *ProfileMapping) (string, bool) {
|
||||
if mapping == nil || mapping.SiloProfileID == nil || strings.TrimSpace(*mapping.SiloProfileID) == "" {
|
||||
return "", false
|
||||
}
|
||||
return *mapping.SiloProfileID, true
|
||||
}
|
||||
|
||||
func buildWebhookURL(baseURL, secret string) string {
|
||||
if baseURL == "" {
|
||||
return webhookSyncPathPrefix + secret
|
||||
|
||||
@@ -42,6 +42,52 @@ func TestBuildWebhookURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWebhookProfileRequiresExplicitMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
linkedProfileID := "linked-profile"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mapping *ProfileMapping
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "missing mapping is skipped",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "unmapped external user is skipped",
|
||||
mapping: &ProfileMapping{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "empty profile mapping is skipped",
|
||||
mapping: &ProfileMapping{SiloProfileID: ptrString("")},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "explicit profile mapping is used",
|
||||
mapping: &ProfileMapping{SiloProfileID: &linkedProfileID},
|
||||
want: linkedProfileID,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := resolveWebhookProfileID(tc.mapping)
|
||||
if ok != tc.wantOK || got != tc.want {
|
||||
t.Fatalf("resolveWebhookProfileID() = (%q, %v), want (%q, %v)", got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDiscoveredAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -82,3 +128,7 @@ func TestFilterDiscoveredAccountsFallsBackWhenFlagsMissing(t *testing.T) {
|
||||
t.Fatalf("unexpected fallback accounts: %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user