Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe934b59d3 |
+3
-2
@@ -72,6 +72,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/radarr"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
@@ -1539,8 +1541,7 @@ func main() {
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# First-Party Audio API
|
||||
|
||||
This API is the Silo-native contract for audiobook playback. Clients should use
|
||||
it for Silo first-party audio experiences instead of the Audiobookshelf
|
||||
compatibility API. Music can reuse the same session, track, and progress
|
||||
concepts when the music UX lands.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require normal Silo user authentication. Playback start, sync,
|
||||
close, and bookmark writes also require the active profile headers used by the
|
||||
first-party clients.
|
||||
|
||||
Track stream URLs are returned from the start response and are scoped to the
|
||||
created audio session. The server validates that the session still belongs to
|
||||
the authenticated user, and to the active profile when a profile is attached.
|
||||
|
||||
## Start Playback
|
||||
|
||||
`POST /api/v1/audio/playback/start`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"content_id": "128814341867700228",
|
||||
"start_position": 120,
|
||||
"restart": false
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"content_id": "128814341867700228",
|
||||
"type": "audiobook",
|
||||
"title": "Book Title",
|
||||
"subtitle": "Author or series subtitle",
|
||||
"poster_url": "https://server/api/v1/images/...",
|
||||
"backdrop_url": "https://server/api/v1/images/...",
|
||||
"total_duration_seconds": 8746,
|
||||
"resume_position_seconds": 120,
|
||||
"tracks": [
|
||||
{
|
||||
"index": 0,
|
||||
"file_id": 123,
|
||||
"file_name": "Part 1.mp3",
|
||||
"duration_seconds": 3600,
|
||||
"start_offset_seconds": 0,
|
||||
"stream_url": "/api/v1/audio/playback/{session_id}/tracks/0",
|
||||
"stream_type": "progressive",
|
||||
"play_method": "direct",
|
||||
"codec": "mp3",
|
||||
"container": "mp3",
|
||||
"bitrate": 128000,
|
||||
"chapters": []
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Chapter 1",
|
||||
"start_seconds": 0,
|
||||
"end_seconds": 1800,
|
||||
"track_index": 0
|
||||
}
|
||||
],
|
||||
"bookmarks": []
|
||||
}
|
||||
```
|
||||
|
||||
`total_duration_seconds`, `resume_position_seconds`, global chapter times, and
|
||||
sync positions are cumulative book seconds, not per-file seconds. Clients map
|
||||
global time to the active track by finding the last track whose
|
||||
`start_offset_seconds` is less than or equal to the target time.
|
||||
|
||||
Tracks are ordered by presentation part index when available, then by path and
|
||||
file id. The server direct-plays AVFoundation-safe formats and returns
|
||||
`play_method: "aac"` for fallback streams that are remuxed/transcoded to an
|
||||
Apple-safe AAC container.
|
||||
|
||||
## Stream Track
|
||||
|
||||
`GET /api/v1/audio/playback/{session_id}/tracks/{track_index}`
|
||||
|
||||
The response is the media stream for the selected track. Clients should request
|
||||
the track for the current global time and seek within that track using:
|
||||
|
||||
`local_seconds = global_seconds - track.start_offset_seconds`
|
||||
|
||||
## Sync Progress
|
||||
|
||||
`PATCH /api/v1/audio/playback/{session_id}/sync`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": false
|
||||
}
|
||||
```
|
||||
|
||||
The server persists `position` as the audiobook progress position. The client
|
||||
should send cumulative book seconds on a throttle while playing, on manual
|
||||
seeks, and once more before closing the session.
|
||||
|
||||
## Close Playback
|
||||
|
||||
`POST /api/v1/audio/playback/{session_id}/close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": true
|
||||
}
|
||||
```
|
||||
|
||||
Close performs a final progress sync and expires the in-memory audio session.
|
||||
|
||||
## Bookmarks
|
||||
|
||||
`GET /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`POST /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`DELETE /api/v1/audio/items/{content_id}/bookmarks/{time_seconds}`
|
||||
|
||||
Create request:
|
||||
|
||||
```json
|
||||
{
|
||||
"time_seconds": 1234,
|
||||
"title": "Chapter note"
|
||||
}
|
||||
```
|
||||
|
||||
Bookmark positions are cumulative book seconds so they remain stable across
|
||||
multi-file audiobooks and client platforms.
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
|
||||
@@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
@@ -26,31 +22,3 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
|
||||
// locks the autoscan→requests credential-reuse behavior after the
|
||||
// request_integrations table was generalized into the two-tier connection
|
||||
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
|
||||
// rows via the same enabled + base_url gate, regardless of the added
|
||||
// capability/installation columns.
|
||||
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
|
||||
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
|
||||
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
|
||||
}
|
||||
// Disabled -> error (autoscan must not poll a disabled connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
|
||||
t.Fatal("disabled integration must be rejected")
|
||||
}
|
||||
// Blank base_url -> error.
|
||||
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
|
||||
t.Fatal("blank base_url must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
|
||||
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
|
||||
// fail the build here rather than silently at runtime.
|
||||
var _ = func(in mediarequests.Integration) (string, string, bool) {
|
||||
return in.BaseURL, in.APIKeyRef, in.Enabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
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/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const audioPlaybackSessionTTL = 12 * time.Hour
|
||||
|
||||
type AudioDetailService interface {
|
||||
GetItemDetail(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.ItemDetail, error)
|
||||
}
|
||||
|
||||
type AudioFileResolver interface {
|
||||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type AudioHandler struct {
|
||||
detailSvc AudioDetailService
|
||||
files AudioFileResolver
|
||||
storeProvider userstore.UserStoreProvider
|
||||
bookmarkStore *PGAudioBookmarkStore
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]audioPlaybackSession
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAudioHandler(
|
||||
detailSvc AudioDetailService,
|
||||
files AudioFileResolver,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
bookmarkStore *PGAudioBookmarkStore,
|
||||
) *AudioHandler {
|
||||
return &AudioHandler{
|
||||
detailSvc: detailSvc,
|
||||
files: files,
|
||||
storeProvider: storeProvider,
|
||||
bookmarkStore: bookmarkStore,
|
||||
sessions: make(map[string]audioPlaybackSession),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
type audioPlaybackSession struct {
|
||||
ID string
|
||||
UserID int
|
||||
ProfileID string
|
||||
ContentID string
|
||||
TotalSeconds float64
|
||||
Files []audioSessionFile
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type audioSessionFile struct {
|
||||
FileID int
|
||||
FilePath string
|
||||
Start float64
|
||||
Duration float64
|
||||
Mode string
|
||||
TrackIdx int
|
||||
}
|
||||
|
||||
type audioStartPlaybackRequest struct {
|
||||
ContentID string `json:"content_id"`
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
Restart bool `json:"restart,omitempty"`
|
||||
}
|
||||
|
||||
type audioProgressRequest struct {
|
||||
Position float64 `json:"position"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
}
|
||||
|
||||
type audioPlaybackResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ContentID string `json:"content_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
TotalDurationSeconds float64 `json:"total_duration_seconds"`
|
||||
ResumePositionSeconds float64 `json:"resume_position_seconds"`
|
||||
Tracks []audioTrackResponse `json:"tracks"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
Bookmarks []audioBookmark `json:"bookmarks"`
|
||||
}
|
||||
|
||||
type audioTrackResponse struct {
|
||||
Index int `json:"index"`
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
StartOffsetSeconds float64 `json:"start_offset_seconds"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
StreamType string `json:"stream_type"`
|
||||
PlayMethod string `json:"play_method"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
type audioChapter struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title,omitempty"`
|
||||
StartSeconds float64 `json:"start_seconds"`
|
||||
EndSeconds float64 `json:"end_seconds,omitempty"`
|
||||
TrackIndex int `json:"track_index,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmark struct {
|
||||
ID string `json:"id"`
|
||||
ContentID string `json:"content_id"`
|
||||
TimeSeconds float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmarkRequest struct {
|
||||
TimeSeconds *float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.detailSvc == nil || h.files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Audio playback is not configured")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audioStartPlaybackRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req.ContentID = strings.TrimSpace(req.ContentID)
|
||||
if req.ContentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), req.ContentID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item detail")
|
||||
return
|
||||
}
|
||||
if detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.files.GetByContentID(r.Context(), req.ContentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load audio files")
|
||||
return
|
||||
}
|
||||
files = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
sortAudioFiles(files)
|
||||
if len(files) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No playable audio files found")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := ulid.Make().String()
|
||||
tracks, sessionFiles, chapters, totalDuration := buildAudioTrackResponses(sessionID, files)
|
||||
if detail.Audiobook != nil && detail.Audiobook.TotalDurationSeconds > 0 {
|
||||
totalDuration = float64(detail.Audiobook.TotalDurationSeconds)
|
||||
}
|
||||
|
||||
resume := resolveAudioResumePosition(r.Context(), h.storeProvider, userID, profileID, req.ContentID)
|
||||
if req.Restart {
|
||||
resume = 0
|
||||
} else if req.StartPosition != nil && isFiniteNonNegative(*req.StartPosition) {
|
||||
resume = clampFloat(*req.StartPosition, 0, totalDuration)
|
||||
}
|
||||
|
||||
bookmarks, _ := h.listBookmarks(r.Context(), userID, profileID, req.ContentID)
|
||||
session := audioPlaybackSession{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: req.ContentID,
|
||||
TotalSeconds: totalDuration,
|
||||
Files: sessionFiles,
|
||||
CreatedAt: h.now(),
|
||||
LastSeenAt: h.now(),
|
||||
ExpiresAt: h.now().Add(audioPlaybackSessionTTL),
|
||||
}
|
||||
h.putSession(session)
|
||||
|
||||
writeJSON(w, http.StatusOK, audioPlaybackResponse{
|
||||
SessionID: sessionID,
|
||||
ContentID: detail.ContentID,
|
||||
Type: detail.Type,
|
||||
Title: detail.Title,
|
||||
Subtitle: audioSubtitle(detail),
|
||||
PosterURL: detail.PosterURL,
|
||||
BackdropURL: detail.BackdropURL,
|
||||
TotalDurationSeconds: totalDuration,
|
||||
ResumePositionSeconds: resume,
|
||||
Tracks: tracks,
|
||||
Chapters: chapters,
|
||||
Bookmarks: bookmarks,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleSyncPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
if err := h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync audio progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": session.ID,
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"is_paused": req.IsPaused,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleClosePlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if isFiniteNonNegative(req.Position) {
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
_ = h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration)
|
||||
}
|
||||
h.deleteSession(session.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStreamTrack(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rawIndex := chi.URLParam(r, "track_index")
|
||||
index, err := strconv.Atoi(rawIndex)
|
||||
if err != nil || index < 0 || index >= len(session.Files) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid track index")
|
||||
return
|
||||
}
|
||||
file := session.Files[index]
|
||||
mode := r.URL.Query().Get("mode")
|
||||
if mode == "" {
|
||||
mode = file.Mode
|
||||
}
|
||||
if mode == "aac" {
|
||||
seek := 0.0
|
||||
if raw := r.URL.Query().Get("seek"); raw != "" {
|
||||
if parsed, err := strconv.ParseFloat(raw, 64); err == nil && parsed >= 0 {
|
||||
seek = parsed
|
||||
}
|
||||
}
|
||||
if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seek, true, 0); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = playback.ServeDirectPlay(w, r, file.FilePath)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleListBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmarks, err := h.listBookmarks(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list bookmarks")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"bookmarks": bookmarks})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleCreateBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
var req audioBookmarkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TimeSeconds == nil || !isFiniteNonNegative(*req.TimeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "time_seconds is required")
|
||||
return
|
||||
}
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), contentID, filter)
|
||||
if err != nil || detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmark, err := h.bookmarkStore.Upsert(r.Context(), userID, profileID, contentID, *req.TimeSeconds, req.Title)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save bookmark")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bookmark)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
rawTime := chi.URLParam(r, "time_seconds")
|
||||
timeSeconds, err := strconv.ParseFloat(rawTime, 64)
|
||||
if err != nil || !isFiniteNonNegative(timeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid bookmark time")
|
||||
return
|
||||
}
|
||||
if err := h.bookmarkStore.Delete(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), contentID, timeSeconds); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete bookmark")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) contentIDParam(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return "", false
|
||||
}
|
||||
return contentID, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) sessionForRequest(w http.ResponseWriter, r *http.Request) (audioPlaybackSession, bool) {
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
if sessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session, ok := h.getSession(sessionID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audio playback session not found")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if session.UserID != apimw.GetUserID(r.Context()) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID != "" && session.ProfileID != profileID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another profile")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) putSession(session audioPlaybackSession) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.pruneExpiredLocked(h.now())
|
||||
h.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (h *AudioHandler) getSession(sessionID string) (audioPlaybackSession, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.now()
|
||||
h.pruneExpiredLocked(now)
|
||||
session, ok := h.sessions[sessionID]
|
||||
if !ok {
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, sessionID)
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session.LastSeenAt = now
|
||||
h.sessions[sessionID] = session
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) deleteSession(sessionID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) pruneExpiredLocked(now time.Time) {
|
||||
for id, session := range h.sessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AudioHandler) persistProgress(ctx context.Context, userID int, profileID, contentID string, position, duration float64) error {
|
||||
if h.storeProvider == nil {
|
||||
return nil
|
||||
}
|
||||
store, err := h.storeProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.SetProgress(ctx, profileID, contentID, position, duration, userstore.ProgressThresholds{})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) listBookmarks(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if h.bookmarkStore == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
return h.bookmarkStore.List(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
func buildAudioTrackResponses(sessionID string, files []*models.MediaFile) ([]audioTrackResponse, []audioSessionFile, []audioChapter, float64) {
|
||||
tracks := make([]audioTrackResponse, 0, len(files))
|
||||
sessionFiles := make([]audioSessionFile, 0, len(files))
|
||||
allChapters := []audioChapter{}
|
||||
offset := 0.0
|
||||
for index, file := range files {
|
||||
duration := float64(file.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
mode := audioDeliveryMode(file)
|
||||
streamURL := fmt.Sprintf("/api/v1/audio/playback/%s/tracks/%d", sessionID, index)
|
||||
if mode == "aac" {
|
||||
streamURL += "?mode=aac"
|
||||
}
|
||||
chapters := audioChaptersForTrack(index, offset, file.Chapters)
|
||||
allChapters = append(allChapters, chapters...)
|
||||
tracks = append(tracks, audioTrackResponse{
|
||||
Index: index,
|
||||
FileID: file.ID,
|
||||
FileName: filepath.Base(file.FilePath),
|
||||
DurationSeconds: duration,
|
||||
StartOffsetSeconds: offset,
|
||||
StreamURL: streamURL,
|
||||
StreamType: "progressive",
|
||||
PlayMethod: mode,
|
||||
Codec: file.CodecAudio,
|
||||
Container: file.Container,
|
||||
Bitrate: file.Bitrate,
|
||||
Chapters: chapters,
|
||||
})
|
||||
sessionFiles = append(sessionFiles, audioSessionFile{
|
||||
FileID: file.ID,
|
||||
FilePath: file.FilePath,
|
||||
Start: offset,
|
||||
Duration: duration,
|
||||
Mode: mode,
|
||||
TrackIdx: index,
|
||||
})
|
||||
offset += duration
|
||||
}
|
||||
return tracks, sessionFiles, allChapters, offset
|
||||
}
|
||||
|
||||
func audioChaptersForTrack(trackIndex int, offset float64, chapters []models.MediaChapter) []audioChapter {
|
||||
out := make([]audioChapter, 0, len(chapters))
|
||||
for _, chapter := range chapters {
|
||||
start := offset + maxFloat(0, chapter.StartSeconds)
|
||||
end := 0.0
|
||||
if chapter.EndSeconds > 0 {
|
||||
end = offset + chapter.EndSeconds
|
||||
}
|
||||
out = append(out, audioChapter{
|
||||
Index: len(out),
|
||||
Title: chapter.Title,
|
||||
StartSeconds: start,
|
||||
EndSeconds: end,
|
||||
TrackIndex: trackIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortAudioFiles(files []*models.MediaFile) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
left, right := files[i], files[j]
|
||||
if left.PresentationPartIndex != right.PresentationPartIndex {
|
||||
if left.PresentationPartIndex == 0 {
|
||||
return false
|
||||
}
|
||||
if right.PresentationPartIndex == 0 {
|
||||
return true
|
||||
}
|
||||
return left.PresentationPartIndex < right.PresentationPartIndex
|
||||
}
|
||||
if left.FilePath != right.FilePath {
|
||||
return left.FilePath < right.FilePath
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
|
||||
func audioDeliveryMode(file *models.MediaFile) string {
|
||||
if file == nil {
|
||||
return "direct"
|
||||
}
|
||||
codec := strings.ToLower(strings.TrimSpace(file.CodecAudio))
|
||||
container := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(file.Container), "."))
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.FilePath), "."))
|
||||
if container == "" {
|
||||
container = ext
|
||||
}
|
||||
switch codec {
|
||||
case "aac", "mp3", "alac", "flac":
|
||||
return "direct"
|
||||
case "":
|
||||
switch container {
|
||||
case "mp3", "m4a", "m4b", "mp4", "aac", "flac", "wav", "aiff", "aif":
|
||||
return "direct"
|
||||
}
|
||||
}
|
||||
return "aac"
|
||||
}
|
||||
|
||||
func audioSubtitle(detail *catalog.ItemDetail) string {
|
||||
if detail == nil || detail.Audiobook == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if len(detail.Audiobook.Authors) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Authors))
|
||||
for _, author := range detail.Audiobook.Authors {
|
||||
if strings.TrimSpace(author.Name) != "" {
|
||||
names = append(names, author.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if len(detail.Audiobook.Narrators) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Narrators))
|
||||
for _, narrator := range detail.Audiobook.Narrators {
|
||||
if strings.TrimSpace(narrator.Name) != "" {
|
||||
names = append(names, narrator.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, "Narrated by "+strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " • ")
|
||||
}
|
||||
|
||||
func resolveAudioResumePosition(ctx context.Context, provider userstore.UserStoreProvider, userID int, profileID, contentID string) float64 {
|
||||
if provider == nil || userID == 0 || profileID == "" {
|
||||
return 0
|
||||
}
|
||||
store, err := provider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
progress, err := store.GetProgress(ctx, profileID, contentID)
|
||||
if err != nil || progress == nil || progress.Completed {
|
||||
return 0
|
||||
}
|
||||
if progress.PositionSeconds < 30 {
|
||||
return 0
|
||||
}
|
||||
if progress.DurationSeconds > 0 && progress.PositionSeconds >= progress.DurationSeconds-5 {
|
||||
return 0
|
||||
}
|
||||
return progress.PositionSeconds
|
||||
}
|
||||
|
||||
func isFiniteNonNegative(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0
|
||||
}
|
||||
|
||||
func clampFloat(value, lower, upper float64) float64 {
|
||||
if !isFiniteNonNegative(value) {
|
||||
return lower
|
||||
}
|
||||
if value < lower {
|
||||
return lower
|
||||
}
|
||||
if upper > lower && value > upper {
|
||||
return upper
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type PGAudioBookmarkStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGAudioBookmarkStore(pool *pgxpool.Pool) *PGAudioBookmarkStore {
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return &PGAudioBookmarkStore{Pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) List(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, library_item_id, time_seconds, title, created_at, updated_at
|
||||
FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
ORDER BY time_seconds ASC`,
|
||||
userID, profileArg(profileID), contentID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
bookmarks := []audioBookmark{}
|
||||
for rows.Next() {
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
if err := rows.Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
bookmarks = append(bookmarks, bookmark)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Upsert(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64, title string) (audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return audioBookmark{}, fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
id := ulid.Make().String()
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO abs_bookmarks
|
||||
(id, user_id, profile_id, library_item_id, time_seconds, title)
|
||||
VALUES ($1, $2, $3::uuid, $4, $5, $6)
|
||||
ON CONFLICT (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_item_id,
|
||||
time_seconds
|
||||
) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
updated_at = now()
|
||||
RETURNING id, library_item_id, time_seconds, title, created_at, updated_at`,
|
||||
id, userID, profileArg(profileID), contentID, timeSeconds, strings.TrimSpace(title),
|
||||
).Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return audioBookmark{}, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Delete(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
AND time_seconds = $4`,
|
||||
userID, profileArg(profileID), contentID, timeSeconds,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func profileArg(profileID string) any {
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return nil
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type stubAudioDetailService struct {
|
||||
detail *catalog.ItemDetail
|
||||
}
|
||||
|
||||
func (s stubAudioDetailService) GetItemDetail(context.Context, string, catalog.AccessFilter) (*catalog.ItemDetail, error) {
|
||||
return s.detail, nil
|
||||
}
|
||||
|
||||
type stubAudioFiles struct {
|
||||
files []*models.MediaFile
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByContentID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return append([]*models.MediaFile(nil), s.files...), nil
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByID(_ context.Context, id int) (*models.MediaFile, error) {
|
||||
for _, file := range s.files {
|
||||
if file.ID == id {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAudioStartPlaybackOrdersTracksAndBuildsGlobalChapters(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(3, "part-03.mp3", 30, 30),
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleStartPlayback(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp audioPlaybackResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Tracks) != 3 {
|
||||
t.Fatalf("tracks = %d, want 3", len(resp.Tracks))
|
||||
}
|
||||
if resp.Tracks[0].FileID != 1 || resp.Tracks[1].FileID != 2 || resp.Tracks[2].FileID != 3 {
|
||||
t.Fatalf("track order = [%d %d %d], want [1 2 3]", resp.Tracks[0].FileID, resp.Tracks[1].FileID, resp.Tracks[2].FileID)
|
||||
}
|
||||
if resp.Tracks[1].StartOffsetSeconds != 10 || resp.Tracks[2].StartOffsetSeconds != 30 {
|
||||
t.Fatalf("offsets = %.0f %.0f, want 10 30", resp.Tracks[1].StartOffsetSeconds, resp.Tracks[2].StartOffsetSeconds)
|
||||
}
|
||||
if len(resp.Chapters) != 3 || resp.Chapters[2].StartSeconds != 30 {
|
||||
t.Fatalf("global chapters = %+v, want third chapter at 30s", resp.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSyncPersistsCumulativeProgressAndEnforcesOwner(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: store},
|
||||
nil,
|
||||
)
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
if startRec.Code != http.StatusOK {
|
||||
t.Fatalf("start status = %d body=%s", startRec.Code, startRec.Body.String())
|
||||
}
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
otherCtx := apimw.SetClaims(context.Background(), &auth.Claims{UserID: 2, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
otherCtx = apimw.SetProfileID(otherCtx, "profile-1")
|
||||
denied := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":12,"duration":30}`))
|
||||
denied = withAudioRouteParam(denied.WithContext(otherCtx), "session_id", start.SessionID)
|
||||
deniedRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(deniedRec, denied)
|
||||
if deniedRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign sync status = %d, want 403", deniedRec.Code)
|
||||
}
|
||||
|
||||
syncReq := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":18,"duration":30}`))
|
||||
syncReq = withAudioRouteParam(syncReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
syncRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(syncRec, syncReq)
|
||||
if syncRec.Code != http.StatusOK {
|
||||
t.Fatalf("sync status = %d body=%s", syncRec.Code, syncRec.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 18 || progress.DurationSeconds != 30 {
|
||||
t.Fatalf("progress = %+v, want 18/30", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSessionExpires(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{testAudioFile(1, "part-01.mp3", 10, 10)}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(audioPlaybackSessionTTL + time.Second)
|
||||
streamReq := httptest.NewRequest(http.MethodHead, "/api/v1/audio/playback/"+start.SessionID+"/tracks/0", nil)
|
||||
streamReq = withAudioRouteParam(streamReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
streamReq = withAudioRouteParam(streamReq, "track_index", "0")
|
||||
streamRec := httptest.NewRecorder()
|
||||
handler.HandleStreamTrack(streamRec, streamReq)
|
||||
if streamRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expired stream status = %d, want 404", streamRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func testAudiobookDetail() *catalog.ItemDetail {
|
||||
return &catalog.ItemDetail{
|
||||
ContentID: "book-1",
|
||||
Type: "audiobook",
|
||||
Title: "Book One",
|
||||
Audiobook: &catalog.AudiobookDetailExtension{
|
||||
Authors: []catalog.AudiobookPerson{{Name: "Author One"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testAudioFile(id int, name string, partIndex int, duration int) *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: id,
|
||||
ContentID: "book-1",
|
||||
FilePath: "/media/" + name,
|
||||
CodecAudio: "mp3",
|
||||
Container: "mp3",
|
||||
Duration: duration,
|
||||
PresentationPartIndex: partIndex,
|
||||
Chapters: []models.MediaChapter{{
|
||||
Index: 0,
|
||||
Title: name,
|
||||
StartSeconds: 0,
|
||||
EndSeconds: float64(duration),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func withAudioRouteParam(req *http.Request, key, value string) *http.Request {
|
||||
routeCtx := chi.RouteContext(req.Context())
|
||||
if routeCtx == nil {
|
||||
routeCtx = chi.NewRouteContext()
|
||||
}
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
@@ -225,9 +225,6 @@ type sortMetricsResponse struct {
|
||||
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
|
||||
ViewedAt string `json:"viewed_at,omitempty"`
|
||||
PlayCount *int `json:"play_count,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
SeriesName string `json:"series_name,omitempty"`
|
||||
}
|
||||
|
||||
// browseResponse is the paginated response for the /items endpoint.
|
||||
@@ -894,90 +891,10 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
case "author", "narrator", "series":
|
||||
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listAudiobookSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
contentIDs := uniqueItemContentIDs(items)
|
||||
if len(contentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var query string
|
||||
switch sortField {
|
||||
case "author":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
|
||||
case "narrator":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
|
||||
case "series":
|
||||
query = `
|
||||
SELECT target.content_id, BTRIM(s.series_name) AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN audiobook_series s ON s.content_id = target.content_id
|
||||
WHERE BTRIM(s.series_name) <> ''`
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var contentID, value string
|
||||
if err := rows.Scan(&contentID, &value); err != nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if contentID == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
resp := metrics[contentID]
|
||||
if resp == nil {
|
||||
resp = &sortMetricsResponse{}
|
||||
metrics[contentID] = resp
|
||||
}
|
||||
switch sortField {
|
||||
case "author":
|
||||
resp.Author = value
|
||||
case "narrator":
|
||||
resp.Narrator = value
|
||||
case "series":
|
||||
resp.SeriesName = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPersonSortMetricQuery(kind int) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT target.content_id, person.name AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN LATERAL (
|
||||
SELECT BTRIM(p.name) AS name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = target.content_id
|
||||
AND ip.kind = %d
|
||||
AND p.name IS NOT NULL
|
||||
AND BTRIM(p.name) <> ''
|
||||
ORDER BY p.name ASC
|
||||
LIMIT 1
|
||||
) person ON TRUE`, kind)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listUserSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
|
||||
@@ -574,11 +574,6 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
|
||||
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
if sections.IsAudiobookLibraryType(folder.Type) {
|
||||
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
|
||||
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
|
||||
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
@@ -2060,7 +2055,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
var candidates []candidate
|
||||
|
||||
for _, c := range caps {
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
|
||||
if defaultPriority > 0 {
|
||||
candidates = append(candidates, candidate{
|
||||
installationID: c.PluginInstallationID,
|
||||
|
||||
@@ -49,8 +49,6 @@ type SessionManagerInterface interface {
|
||||
SetEffectiveMediaFileID(sessionID string, fileID int) error
|
||||
SetTranscodeNodeURL(sessionID, url string) error
|
||||
SetWebSocket(sessionID string, connected bool) error
|
||||
SetRealtimeConnection(sessionID string, connected bool) error
|
||||
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
|
||||
StopSession(sessionID string) error
|
||||
GetSession(sessionID string) (*playback.Session, error)
|
||||
}
|
||||
@@ -259,7 +257,6 @@ type startPlaybackRequest struct {
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
||||
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
|
||||
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
|
||||
CodecsVideo []string `json:"codecs_video"`
|
||||
CodecsAudio []string `json:"codecs_audio"`
|
||||
Containers []string `json:"containers"`
|
||||
@@ -764,9 +761,6 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||||
targetID := playbackProgressTarget(file)
|
||||
@@ -805,7 +799,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||||
if session == nil || session.Position <= 0 {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
|
||||
@@ -1287,13 +1281,6 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
setPlaybackSessionLogContext(r, session.ID)
|
||||
if req.DisableProgressPersistence {
|
||||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||||
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
|
||||
} else {
|
||||
session.DisableProgressPersistence = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||||
slog.Error("failed to set audio track", "session", session.ID, "error", err)
|
||||
@@ -1333,7 +1320,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
h.restoreSessionProgress(r.Context(), session, file)
|
||||
}
|
||||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
if h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
targetID := playbackProgressTarget(effectiveFile)
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||||
@@ -1562,7 +1549,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
// Persist progress to UserStore (best-effort).
|
||||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||||
h.persistProgress(r.Context(), sess)
|
||||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
|
||||
targetID := playbackProgressTarget(file)
|
||||
if targetID != "" {
|
||||
|
||||
@@ -129,10 +129,6 @@ func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return
|
||||
|
||||
func (failingSessionManager) SetWebSocket(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) SetRealtimeConnection(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) SetProgressPersistenceDisabled(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) StopSession(string) error { return nil }
|
||||
|
||||
func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil }
|
||||
@@ -287,64 +283,6 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil {
|
||||
t.Fatalf("seed progress: %v", err)
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "book-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "book.m4b"),
|
||||
Duration: 600,
|
||||
}
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != 201 {
|
||||
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp playbackSessionResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`))
|
||||
progressReq = progressReq.WithContext(newAuthorizedPlaybackContext())
|
||||
progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID)
|
||||
progressRR := httptest.NewRecorder()
|
||||
handler.HandleUpdateProgress(progressRR, progressReq)
|
||||
if progressRR.Code != 204 {
|
||||
t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String())
|
||||
}
|
||||
|
||||
stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil)
|
||||
stopReq = stopReq.WithContext(newAuthorizedPlaybackContext())
|
||||
stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID)
|
||||
stopRR := httptest.NewRecorder()
|
||||
handler.HandleStopPlayback(stopRR, stopReq)
|
||||
if stopRR.Code != 204 {
|
||||
t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 {
|
||||
t.Fatalf("progress after disabled session = %+v, want 500/1200", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
|
||||
file := &models.MediaFile{Duration: 3600}
|
||||
|
||||
|
||||
@@ -169,27 +169,22 @@ type pluginConfigSchemaJSON struct {
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormFieldJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormOptionJSON struct {
|
||||
@@ -198,31 +193,6 @@ type pluginAdminFormOptionJSON struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormConditionJSON struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type pluginAdminFormValidationJSON struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormSectionJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
}
|
||||
|
||||
type pluginCapabilityJSON struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
@@ -690,7 +660,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
|
||||
continue
|
||||
}
|
||||
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
|
||||
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
|
||||
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, level)
|
||||
}); err != nil {
|
||||
slog.Warn("failed to append provider to library chains",
|
||||
"installation_id", installation.ID,
|
||||
@@ -1372,78 +1342,26 @@ func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *pluginAdminFormValidationJSON
|
||||
if v := field.GetValidation(); v != nil {
|
||||
validation = &pluginAdminFormValidationJSON{
|
||||
HasMin: v.GetHasMin(),
|
||||
Min: v.GetMin(),
|
||||
HasMax: v.GetHasMax(),
|
||||
Max: v.GetMax(),
|
||||
Pattern: v.GetPattern(),
|
||||
MinLength: v.GetMinLength(),
|
||||
MaxLength: v.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, pluginAdminFormFieldJSON{
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(),
|
||||
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
|
||||
Validation: validation,
|
||||
ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, pluginAdminFormSectionJSON{
|
||||
Key: section.GetKey(),
|
||||
Title: section.GetTitle(),
|
||||
Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(),
|
||||
CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
|
||||
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
})
|
||||
}
|
||||
return &pluginAdminFormJSON{
|
||||
Fields: fields,
|
||||
SubmitLabel: form.GetSubmitLabel(),
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
|
||||
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
|
||||
if len(conditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginAdminFormConditionJSON{
|
||||
Field: condition.GetField(),
|
||||
Equals: append([]string(nil), condition.GetEquals()...),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
|
||||
response := make([]pluginCapabilityJSON, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
|
||||
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
|
||||
// this, the schema-driven request-connection form silently loses its
|
||||
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
|
||||
// validation, sections, and the MULTI_SELECT control mapping, which makes the
|
||||
// new form non-functional (required fields can never be set, save stays
|
||||
// disabled).
|
||||
func TestAdminFormToJSONSchemaFields(t *testing.T) {
|
||||
descriptor := &pluginv1.AdminFormDescriptor{
|
||||
SubmitLabel: "Connect",
|
||||
Fields: []*pluginv1.AdminFormField{
|
||||
{
|
||||
Key: "quality_profile_ids",
|
||||
Label: "Quality Profiles",
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
|
||||
DynamicOptions: true,
|
||||
ExclusiveGroupField: "service_kind",
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
|
||||
},
|
||||
Validation: &pluginv1.AdminFormValidation{
|
||||
HasMin: true,
|
||||
Min: 1,
|
||||
HasMax: true,
|
||||
Max: 10,
|
||||
Pattern: "^[0-9]+$",
|
||||
MinLength: 1,
|
||||
MaxLength: 64,
|
||||
},
|
||||
},
|
||||
},
|
||||
Sections: []*pluginv1.AdminFormSection{
|
||||
{
|
||||
Key: "advanced",
|
||||
Title: "Advanced",
|
||||
Description: "Optional tuning",
|
||||
Collapsible: true,
|
||||
CollapsedDefault: true,
|
||||
FieldKeys: []string{"quality_profile_ids"},
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
form := adminFormToJSON(descriptor)
|
||||
if form == nil {
|
||||
t.Fatal("adminFormToJSON returned nil")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal admin form: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Fields []struct {
|
||||
Control string `json:"control"`
|
||||
DynamicOptions bool `json:"dynamic_options"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
Validation *struct {
|
||||
HasMin bool `json:"has_min"`
|
||||
Min float64 `json:"min"`
|
||||
HasMax bool `json:"has_max"`
|
||||
Max float64 `json:"max"`
|
||||
Pattern string `json:"pattern"`
|
||||
MinLength int32 `json:"min_length"`
|
||||
MaxLength int32 `json:"max_length"`
|
||||
} `json:"validation"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field"`
|
||||
} `json:"fields"`
|
||||
Sections []struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
|
||||
}
|
||||
|
||||
if len(decoded.Fields) != 1 {
|
||||
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
|
||||
}
|
||||
field := decoded.Fields[0]
|
||||
|
||||
// Control must serialize to the SHORT form the frontend switches on.
|
||||
if field.Control != "MULTI_SELECT" {
|
||||
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
|
||||
}
|
||||
if !field.DynamicOptions {
|
||||
t.Error("dynamic_options = false, want true")
|
||||
}
|
||||
if field.ExclusiveGroupField != "service_kind" {
|
||||
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
|
||||
}
|
||||
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
|
||||
len(field.ShowWhen[0].Equals) != 2 {
|
||||
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
|
||||
}
|
||||
if field.Validation == nil {
|
||||
t.Fatal("validation not serialized")
|
||||
}
|
||||
if !field.Validation.HasMin || field.Validation.Min != 1 ||
|
||||
!field.Validation.HasMax || field.Validation.Max != 10 ||
|
||||
field.Validation.Pattern != "^[0-9]+$" ||
|
||||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
|
||||
t.Errorf("validation not serialized correctly: %+v", field.Validation)
|
||||
}
|
||||
|
||||
if len(decoded.Sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
|
||||
}
|
||||
section := decoded.Sections[0]
|
||||
if section.Key != "advanced" || section.Title != "Advanced" ||
|
||||
section.Description != "Optional tuning" || !section.Collapsible ||
|
||||
!section.CollapsedDefault {
|
||||
t.Errorf("section scalar fields not serialized correctly: %+v", section)
|
||||
}
|
||||
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
|
||||
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
|
||||
}
|
||||
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
|
||||
len(section.ShowWhen[0].Equals) != 1 {
|
||||
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
|
||||
}
|
||||
}
|
||||
|
||||
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
|
||||
// renames. The DTO field names currently mirror the proto field names 1:1, so
|
||||
// this is empty; add an entry only when a DTO field is deliberately named
|
||||
// differently from its proto source (the test then accepts the renamed target).
|
||||
var adminFormDTOMappingRenames = map[string]string{}
|
||||
|
||||
// protoInternalFields are the protobuf-runtime bookkeeping fields every
|
||||
// generated message struct carries. They are never serialized to the client
|
||||
// DTO, so the completeness guard skips them.
|
||||
var protoInternalFields = map[string]bool{
|
||||
"state": true,
|
||||
"sizeCache": true,
|
||||
"unknownFields": true,
|
||||
}
|
||||
|
||||
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
|
||||
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
|
||||
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
|
||||
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
|
||||
// later. It reflects over each proto message's exported fields and asserts the
|
||||
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
|
||||
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
|
||||
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
|
||||
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
|
||||
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
|
||||
// risky; this guard is the proportionate fix.)
|
||||
func TestAdminFormDTOCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
proto reflect.Type
|
||||
dto reflect.Type
|
||||
}{
|
||||
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
|
||||
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
|
||||
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dtoFields := map[string]bool{}
|
||||
for i := 0; i < tc.dto.NumField(); i++ {
|
||||
dtoFields[tc.dto.Field(i).Name] = true
|
||||
}
|
||||
|
||||
for i := 0; i < tc.proto.NumField(); i++ {
|
||||
pf := tc.proto.Field(i)
|
||||
if !pf.IsExported() {
|
||||
continue // unexported protobuf-internal state
|
||||
}
|
||||
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
|
||||
continue
|
||||
}
|
||||
want := pf.Name
|
||||
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
|
||||
want = mapped
|
||||
}
|
||||
if !dtoFields[want] {
|
||||
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
|
||||
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
|
||||
"(or record an intentional rename in adminFormDTOMappingRenames).",
|
||||
tc.name, pf.Name, want, tc.dto.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,6 @@ type rateLimitConfigResponse struct {
|
||||
IPReqPerMinute float64 `json:"ip_requests_per_minute"`
|
||||
IPBurst int `json:"ip_burst"`
|
||||
AuthEndpoints map[string]authEndpointConfigResponse `json:"auth_endpoints"`
|
||||
// Active reports whether a limiter is running in this process. The
|
||||
// limiter is constructed at startup, so config saved while it is absent
|
||||
// (or a backend change while it is running) needs a restart to apply.
|
||||
Active bool `json:"active"`
|
||||
// ActiveBackend is the backend the running limiter actually uses, which
|
||||
// can differ from Backend until the server restarts.
|
||||
ActiveBackend string `json:"active_backend,omitempty"`
|
||||
}
|
||||
|
||||
type tierConfigResponse struct {
|
||||
@@ -71,10 +64,6 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques
|
||||
IPReqPerMinute: cfg.IPReqPerMinute,
|
||||
IPBurst: cfg.IPBurst,
|
||||
AuthEndpoints: make(map[string]authEndpointConfigResponse),
|
||||
Active: h.mw != nil,
|
||||
}
|
||||
if h.mw != nil {
|
||||
resp.ActiveBackend = h.mw.ActiveBackend()
|
||||
}
|
||||
for name, tier := range cfg.Tiers {
|
||||
resp.Tiers[name] = tierConfigResponse{
|
||||
@@ -162,21 +151,10 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
// The limiter is constructed at startup, so enabling while it is absent
|
||||
// or switching backend while it runs only takes effect after a restart.
|
||||
// Everything else hot-reloads below.
|
||||
restartRequired := false
|
||||
if h.mw == nil {
|
||||
restartRequired = req.Enabled
|
||||
} else {
|
||||
if (req.Backend == "memory" || req.Backend == "redis") && req.Backend != h.mw.ActiveBackend() {
|
||||
restartRequired = true
|
||||
}
|
||||
// Hot-reload: apply new config immediately on this instance
|
||||
if err := h.mw.Reload(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
|
||||
return
|
||||
}
|
||||
// Hot-reload: apply new config immediately on this instance
|
||||
if err := h.mw.Reload(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Publish for multi-instance reload (if EventBus is available/backed by Redis)
|
||||
@@ -187,5 +165,5 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired})
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
)
|
||||
|
||||
type fakeRateLimitStore struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func newFakeRateLimitStore() *fakeRateLimitStore {
|
||||
return &fakeRateLimitStore{values: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
|
||||
s.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(s.values))
|
||||
for k, v := range s.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
|
||||
t.Helper()
|
||||
perKey := ratelimit.NewMemoryLimiter()
|
||||
global := ratelimit.NewMemoryLimiter()
|
||||
t.Cleanup(func() {
|
||||
perKey.Close()
|
||||
global.Close()
|
||||
})
|
||||
mw := ratelimit.NewMiddleware(perKey, global, store, true)
|
||||
if err := mw.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init middleware: %v", err)
|
||||
}
|
||||
return mw
|
||||
}
|
||||
|
||||
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUpdateConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode PUT response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp rateLimitConfigResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode GET response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
|
||||
// The limiter is nil when rate limiting was disabled at boot. The config
|
||||
// endpoints must still work so an admin can re-enable it from the UI.
|
||||
store := newFakeRateLimitStore()
|
||||
store.values["ratelimit.enabled"] = "false"
|
||||
h := NewRateLimitHandler(store, nil, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if got.Enabled {
|
||||
t.Error("GET enabled = true, want false")
|
||||
}
|
||||
if got.Active {
|
||||
t.Error("GET active = true, want false when no limiter is running")
|
||||
}
|
||||
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
if store.values["ratelimit.enabled"] != "true" {
|
||||
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
|
||||
}
|
||||
if store.values["ratelimit.backend"] != "redis" {
|
||||
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
|
||||
}
|
||||
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
|
||||
store := newFakeRateLimitStore()
|
||||
mw := newRunningRateLimitMiddleware(t, store)
|
||||
h := NewRateLimitHandler(store, mw, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if !got.Active {
|
||||
t.Error("GET active = false, want true with a running limiter")
|
||||
}
|
||||
if got.ActiveBackend != "memory" {
|
||||
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
|
||||
}
|
||||
|
||||
// Toggling enabled hot-reloads; no restart needed when the backend matches.
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
|
||||
// Switching backend only takes effect at boot.
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type RequestService interface {
|
||||
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error)
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
|
||||
|
||||
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
@@ -588,36 +588,62 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
|
||||
}
|
||||
|
||||
type requestIntegrationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
SupportedMediaTypes []string `json:"supported_media_types"`
|
||||
PluginConfig map[string]any `json:"plugin_config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Is4K bool `json:"is_4k"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsDefault4K bool `json:"is_default_4k"`
|
||||
AnimeEnabled bool `json:"anime_enabled"`
|
||||
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
|
||||
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
|
||||
AnimeTags []int `json:"anime_tags"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
QualityProfileID *int `json:"quality_profile_id,omitempty"`
|
||||
Tags []int `json:"tags"`
|
||||
Options map[string]any `json:"options"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
|
||||
// Normalize nil slices so they serialize as [] (not null); the frontend
|
||||
// types them as number[] and indexes/maps over them.
|
||||
tags := integration.Tags
|
||||
if tags == nil {
|
||||
tags = []int{}
|
||||
}
|
||||
animeTags := integration.AnimeTags
|
||||
if animeTags == nil {
|
||||
animeTags = []int{}
|
||||
}
|
||||
return requestIntegrationResponse{
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
CapabilityID: integration.CapabilityID,
|
||||
InstallationID: integration.InstallationID,
|
||||
SupportedMediaTypes: integration.SupportedMediaTypes,
|
||||
PluginConfig: integration.PluginConfig,
|
||||
Enabled: integration.Enabled,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
Kind: integration.Kind,
|
||||
Enabled: integration.Enabled,
|
||||
Is4K: integration.Is4K,
|
||||
IsDefault: integration.IsDefault,
|
||||
IsDefault4K: integration.IsDefault4K,
|
||||
AnimeEnabled: integration.AnimeEnabled,
|
||||
AnimeQualityProfileID: integration.AnimeQualityProfileID,
|
||||
AnimeRootFolder: integration.AnimeRootFolder,
|
||||
AnimeTags: animeTags,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
RootFolder: integration.RootFolder,
|
||||
QualityProfileID: integration.QualityProfileID,
|
||||
Tags: tags,
|
||||
Options: integration.Options,
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,18 +656,6 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI
|
||||
}
|
||||
|
||||
func writeRequestServiceError(w http.ResponseWriter, err error) {
|
||||
// Plugin/instance validation failures carry inline field/form errors; surface
|
||||
// them as a structured 400 so any handler routing through here renders them
|
||||
// inline. Checked first because *ValidationError does not wrap a sentinel.
|
||||
var verr *mediarequests.ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "validation_failed",
|
||||
"field_errors": verr.FieldErrors,
|
||||
"form_error": verr.FormError,
|
||||
})
|
||||
return
|
||||
}
|
||||
var quota mediarequests.QuotaError
|
||||
switch {
|
||||
case errors.As(err, "a):
|
||||
|
||||
@@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) {
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -157,11 +157,8 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
|
||||
}
|
||||
|
||||
cfg := sections.ParseConfigFilters(config)
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" && cfg.FilterType != "audiobook" {
|
||||
return "filter_type must be 'movie', 'series', or 'audiobook'", false
|
||||
}
|
||||
if _, err := sections.ParseContinueType(config); err != nil {
|
||||
return err.Error(), false
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
|
||||
return "filter_type must be 'movie' or 'series'", false
|
||||
}
|
||||
|
||||
var rawLibraryConfig struct {
|
||||
@@ -1411,8 +1408,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
|
||||
return resolved
|
||||
}
|
||||
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry after the
|
||||
// contiguous continue rows that start with the video Continue Watching row.
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after
|
||||
// SectionContinueWatching in the resolved sections list.
|
||||
func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection {
|
||||
nextUp := sections.ResolvedSection{
|
||||
ID: "system-next-up",
|
||||
@@ -1421,20 +1418,6 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
ItemLimit: 20,
|
||||
}
|
||||
|
||||
for i, s := range resolved {
|
||||
if s.SectionType == sections.SectionContinueWatching && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching {
|
||||
insertAt := i + 1
|
||||
for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching {
|
||||
insertAt++
|
||||
}
|
||||
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
||||
result = append(result, resolved[:insertAt]...)
|
||||
result = append(result, nextUp)
|
||||
result = append(result, resolved[insertAt:]...)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range resolved {
|
||||
if s.SectionType == sections.SectionContinueWatching {
|
||||
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
||||
@@ -1445,7 +1428,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
}
|
||||
}
|
||||
|
||||
// If no resume row is found, prepend.
|
||||
// If no continue_watching found, prepend
|
||||
return append([]sections.ResolvedSection{nextUp}, resolved...)
|
||||
}
|
||||
|
||||
|
||||
@@ -282,66 +282,6 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) {
|
||||
tests := []string{
|
||||
`{"continue_type":"watching"}`,
|
||||
`{"continue_type":"listening"}`,
|
||||
`{"continue_type":"reading"}`,
|
||||
`{"filter_type":"audiobook"}`,
|
||||
}
|
||||
|
||||
for _, config := range tests {
|
||||
t.Run(config, func(t *testing.T) {
|
||||
if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok {
|
||||
t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) {
|
||||
msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`))
|
||||
if ok {
|
||||
t.Fatal("validateSectionConfig accepted unknown continue_type")
|
||||
}
|
||||
if msg != "continue_type must be 'watching', 'listening', or 'reading'" {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) {
|
||||
in := []sections.ResolvedSection{
|
||||
{
|
||||
ID: "cw",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Watching",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
|
||||
},
|
||||
{
|
||||
ID: "cl",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Listening",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeListening),
|
||||
},
|
||||
{ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"},
|
||||
}
|
||||
|
||||
got := injectNextUpSection(in)
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, section := range got {
|
||||
gotIDs = append(gotIDs, section.ID)
|
||||
}
|
||||
wantIDs := []string{"cw", "cl", "system-next-up", "recent"}
|
||||
if len(gotIDs) != len(wantIDs) {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if gotIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) {
|
||||
in := []sections.SectionWithItems{
|
||||
// empty seasonal — drop
|
||||
|
||||
@@ -20,7 +20,6 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
|
||||
const (
|
||||
libraryPageStateSettingKey = "ui.library_page_state"
|
||||
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
|
||||
searchMediaScopeSettingKey = "search.media_scope"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -172,14 +171,6 @@ var settingsRegistry = map[string]settingSpec{
|
||||
DefaultValue: "true",
|
||||
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
|
||||
},
|
||||
// Preferred default scope for global/catalog search. "video" keeps
|
||||
// results to movies and series; "all" mixes audiobooks in.
|
||||
searchMediaScopeSettingKey: {
|
||||
Scope: scopeUser,
|
||||
DefaultValue: "video",
|
||||
Validate: validateEnumSetting(searchMediaScopeSettingKey,
|
||||
"all", "video", "audiobook"),
|
||||
},
|
||||
"player.hdr_enabled": {
|
||||
Scope: scopeDevice,
|
||||
DefaultValue: "true",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
// PluginRequestRouterAdapter adapts plugins.Service to
|
||||
// mediarequests.RouterClientResolver. The concrete *pluginhost.RequestRouterClient
|
||||
// satisfies mediarequests.RouterClient, so the adapter names the interface as its
|
||||
// return type (Go has no return-type covariance).
|
||||
type PluginRequestRouterAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterClient, error) {
|
||||
if a.Svc == nil {
|
||||
return nil, errors.New("request router plugin service is not configured")
|
||||
}
|
||||
return a.Svc.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
}
|
||||
|
||||
// AttachRequestRouter wires the plugin-backed router provider onto a requests
|
||||
// service. Both the HTTP handler and the reconcile task call this so the wiring
|
||||
// lives in one place. With either dependency absent (e.g. a build without the
|
||||
// plugin service) it leaves the requests service router-less, so fulfillment
|
||||
// degrades to the existing "no backend configured" failure instead of panicking.
|
||||
func AttachRequestRouter(svc *mediarequests.Service, pluginService *plugins.Service) {
|
||||
if svc == nil || pluginService == nil {
|
||||
return
|
||||
}
|
||||
svc.SetRouterProvider(mediarequests.NewPluginRouterProvider(PluginRequestRouterAdapter{pluginService}))
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AttachRequestRouter must tolerate absent dependencies (e.g. a build with the
|
||||
// plugin service disabled) without panicking — fulfillment then degrades to the
|
||||
// existing "no backend configured" failure rather than crashing the wiring.
|
||||
func TestAttachRequestRouterNilDependenciesDoesNotPanic(t *testing.T) {
|
||||
AttachRequestRouter(nil, nil)
|
||||
}
|
||||
|
||||
// A router adapter installed without a plugin service must return a controlled
|
||||
// error rather than panic on the request path.
|
||||
func TestPluginRequestRouterAdapterNilServiceReturnsError(t *testing.T) {
|
||||
adapter := PluginRequestRouterAdapter{}
|
||||
if _, err := adapter.RequestRouterClient(context.Background(), 1, "request_router.v1"); err == nil {
|
||||
t.Fatal("RequestRouterClient with nil Svc = nil error, want a controlled error")
|
||||
}
|
||||
}
|
||||
+30
-7
@@ -48,6 +48,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/radarr"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
@@ -439,8 +441,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
tmdb.NewClient(tmdbAPIKey, 40),
|
||||
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
|
||||
)
|
||||
AttachRequestRouter(requestSvc, deps.PluginService)
|
||||
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if viewerResolver != nil {
|
||||
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
|
||||
}
|
||||
@@ -581,6 +582,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
var playbackCommandDispatcher *playback.CommandDispatcher
|
||||
var streamHandler *handlers.StreamHandler
|
||||
var watchTogetherHandler *handlers.WatchTogetherHandler
|
||||
var audioHandler *handlers.AudioHandler
|
||||
if deps.SessionMgr != nil {
|
||||
var playbackAdminStore handlers.PlaybackAdminStore
|
||||
if deps.DB != nil {
|
||||
@@ -690,6 +692,14 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
)
|
||||
}
|
||||
}
|
||||
if detailSvc != nil && deps.FileRepo != nil && deps.UserStoreProvider != nil {
|
||||
audioHandler = handlers.NewAudioHandler(
|
||||
detailSvc,
|
||||
deps.FileRepo,
|
||||
deps.UserStoreProvider,
|
||||
handlers.NewPGAudioBookmarkStore(deps.DB),
|
||||
)
|
||||
}
|
||||
|
||||
// Wire subtitle repo and S3 client onto streamHandler for S3-stored subtitle serving.
|
||||
if streamHandler != nil && subtitleRepo != nil && deps.S3Public != nil {
|
||||
@@ -1769,6 +1779,22 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
if audioHandler != nil {
|
||||
r.Route("/audio", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.RequireProfile)
|
||||
r.Post("/playback/start", audioHandler.HandleStartPlayback)
|
||||
r.Patch("/playback/{session_id}/sync", audioHandler.HandleSyncPlayback)
|
||||
r.Post("/playback/{session_id}/close", audioHandler.HandleClosePlayback)
|
||||
r.Get("/items/{content_id}/bookmarks", audioHandler.HandleListBookmarks)
|
||||
r.Post("/items/{content_id}/bookmarks", audioHandler.HandleCreateBookmark)
|
||||
r.Delete("/items/{content_id}/bookmarks/{time_seconds}", audioHandler.HandleDeleteBookmark)
|
||||
})
|
||||
r.Get("/playback/{session_id}/tracks/{track_index}", audioHandler.HandleStreamTrack)
|
||||
r.Head("/playback/{session_id}/tracks/{track_index}", audioHandler.HandleStreamTrack)
|
||||
})
|
||||
}
|
||||
|
||||
if watchTogetherHandler != nil {
|
||||
r.Route("/watch-together", func(r chi.Router) {
|
||||
r.Get("/rooms/{room_id}/ws", watchTogetherHandler.HandleRoomWebSocket)
|
||||
@@ -2156,11 +2182,8 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Rate limit admin routes. Mounted even when the limiter is not
|
||||
// running (deps.RateLimitMW == nil) so admins can always reach the
|
||||
// config — otherwise disabling rate limiting and restarting would
|
||||
// permanently lock the settings page out of re-enabling it.
|
||||
if settingsRepo != nil {
|
||||
// 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)
|
||||
|
||||
@@ -44,8 +44,7 @@ const (
|
||||
|
||||
// defaultEnrichBatchSize is the maximum number of audiobook items processed
|
||||
// per sweep invocation. Keeps latency bounded for large libraries.
|
||||
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
|
||||
defaultEnrichBatchSize = 250
|
||||
defaultEnrichBatchSize = 50
|
||||
// defaultEnrichWorkers is the default fan-out used by Enricher.Run.
|
||||
// Network-bound: each worker holds one provider HTTP call at a time, so
|
||||
// 4 is enough to mask single-request latency without hammering plugins.
|
||||
@@ -53,28 +52,18 @@ const (
|
||||
defaultEnrichWorkers = 4
|
||||
)
|
||||
|
||||
// audiobookEnrichBatchSize returns the configured maximum sweep size.
|
||||
func audiobookEnrichBatchSize() int {
|
||||
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultEnrichBatchSize
|
||||
}
|
||||
|
||||
// audiobookEnrichWorkers returns the configured number of parallel enrichment
|
||||
// workers, capped to the active batch size so workers never outnumber the batch
|
||||
// they drain.
|
||||
func audiobookEnrichWorkers(batchSize int) int {
|
||||
// workers, capped to defaultEnrichBatchSize so workers never outnumber the
|
||||
// batch they drain.
|
||||
func audiobookEnrichWorkers() int {
|
||||
n := defaultEnrichWorkers
|
||||
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_WORKERS"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
if batchSize > 0 && n > batchSize {
|
||||
n = batchSize
|
||||
if n > defaultEnrichBatchSize {
|
||||
n = defaultEnrichBatchSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -116,7 +105,6 @@ func NewEnricher(
|
||||
personRepo *catalog.PersonRepository,
|
||||
providerIDs *catalog.ProviderIDRepository,
|
||||
) *Enricher {
|
||||
batchSize := audiobookEnrichBatchSize()
|
||||
return &Enricher{
|
||||
pool: pool,
|
||||
chainRepo: chainRepo,
|
||||
@@ -124,8 +112,8 @@ func NewEnricher(
|
||||
itemRepo: itemRepo,
|
||||
personRepo: personRepo,
|
||||
providerIDs: providerIDs,
|
||||
batchSize: batchSize,
|
||||
workers: audiobookEnrichWorkers(batchSize),
|
||||
batchSize: defaultEnrichBatchSize,
|
||||
workers: audiobookEnrichWorkers(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,32 +166,6 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
return enriched, nil
|
||||
}
|
||||
|
||||
// HasPendingItems reports whether a scheduled sweep has any audiobook rows to
|
||||
// process. It mirrors claimBatch's eligibility predicate without loading rows
|
||||
// or provider IDs, so the scheduler can skip no-op executions without adding
|
||||
// task-history noise.
|
||||
func (e *Enricher) HasPendingItems(ctx context.Context) (bool, error) {
|
||||
if e == nil || e.pool == nil || e.chainRepo == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := e.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM media_items mi
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND (mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
AND mi.last_refreshed IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
`).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking pending audiobook enrichment: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// runBatch processes items in parallel using e.workers goroutines. The
|
||||
// enrichFn parameter exists for testing: production calls pass e.enrichItem.
|
||||
// Returns the count of items where enrichFn returned nil.
|
||||
|
||||
@@ -63,50 +63,6 @@ func TestEnricherRunFansOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
|
||||
|
||||
e := NewEnricher(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if e.batchSize != 123 {
|
||||
t.Fatalf("batchSize = %d, want 123", e.batchSize)
|
||||
}
|
||||
if e.workers != 8 {
|
||||
t.Fatalf("workers = %d, want 8", e.workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
|
||||
|
||||
e := NewEnricher(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if e.batchSize != 50 {
|
||||
t.Fatalf("batchSize = %d, want 50", e.batchSize)
|
||||
}
|
||||
if e.workers != e.batchSize {
|
||||
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
|
||||
|
||||
if got := audiobookEnrichWorkers(3); got != 3 {
|
||||
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
|
||||
|
||||
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
|
||||
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
|
||||
cacher := &fakeAudiobookImageCacher{}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
|
||||
@@ -106,23 +106,6 @@ func (r *Refresher) WithHTTPClient(hc *http.Client) *Refresher {
|
||||
return r
|
||||
}
|
||||
|
||||
// HasDue reports whether RefreshDue would attempt at least one feed. The task
|
||||
// scheduler uses this to avoid recording empty maintenance runs when no podcast
|
||||
// feeds exist or every feed is still inside its refresh window.
|
||||
func (r *Refresher) HasDue(ctx context.Context, s Store) (bool, error) {
|
||||
feeds, err := s.ListPodcastFeeds(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("list podcast feeds: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, f := range feeds {
|
||||
if shouldRefreshFeed(f, now) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RefreshDue walks every podcast_feeds row and refreshes those whose
|
||||
// last_refreshed_at + refresh_interval has elapsed. Per-feed failures are
|
||||
// logged at Warn and do not abort the walk. Returns the count of feeds
|
||||
@@ -135,7 +118,10 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
now := time.Now()
|
||||
attempted := 0
|
||||
for _, f := range feeds {
|
||||
if !shouldRefreshFeed(f, now) {
|
||||
if f.FeedURL == "" {
|
||||
continue
|
||||
}
|
||||
if !isDue(f, now) {
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
@@ -150,10 +136,6 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
return attempted, nil
|
||||
}
|
||||
|
||||
func shouldRefreshFeed(f PodcastFeed, now time.Time) bool {
|
||||
return f.FeedURL != "" && isDue(f, now)
|
||||
}
|
||||
|
||||
// RefreshOne refreshes a single podcast feed. Public so the admin
|
||||
// force-refresh endpoint can call it directly. Records success or failure
|
||||
// in podcast_feeds.last_refresh_error regardless of outcome.
|
||||
|
||||
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
|
||||
if deps.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
|
||||
return deps.Detail.PresignURL(ctx, path, variant)
|
||||
},
|
||||
})
|
||||
s.ABSHandler = h
|
||||
|
||||
@@ -3,14 +3,7 @@ package audiobooks
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
type fakeSettingsReader struct {
|
||||
@@ -81,50 +74,3 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
|
||||
resolver := &recordingImageResolver{}
|
||||
detail := &catalog.DetailService{}
|
||||
detail.SetImageResolver(resolver)
|
||||
|
||||
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
|
||||
Items: &catalog.ItemRepository{},
|
||||
Files: &scanner.FileRepository{},
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
coverResolver := absCoverResolverForTest(t, handler)
|
||||
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
|
||||
|
||||
if !strings.Contains(got, "/w500.webp") {
|
||||
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
|
||||
}
|
||||
if resolver.variant != "featured" {
|
||||
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
|
||||
}
|
||||
}
|
||||
|
||||
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
|
||||
t.Helper()
|
||||
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
|
||||
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
|
||||
}
|
||||
|
||||
type recordingImageResolver struct {
|
||||
path string
|
||||
variant string
|
||||
}
|
||||
|
||||
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
|
||||
r.path = path
|
||||
r.variant = variant
|
||||
return "resolved://" + path
|
||||
}
|
||||
|
||||
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
|
||||
out := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
out[path] = r.ResolveImageURL(context.Background(), path, variant)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ type BrowseFilters struct {
|
||||
MaxLimit int // optional caller-specific cap; zero keeps the default browse cap
|
||||
Offset int
|
||||
SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp
|
||||
RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter)
|
||||
}
|
||||
|
||||
// BrowseResult contains the paginated result of a browse query.
|
||||
@@ -296,13 +295,6 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so
|
||||
// random/limited selections only ever pick items that actually have a
|
||||
// backdrop — filtering after the LIMIT would wrongly return empty pages.
|
||||
if filters.RequireBackdrop {
|
||||
conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL")
|
||||
}
|
||||
|
||||
// Library access control: restrict to user's accessible libraries.
|
||||
needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added"
|
||||
needsPersonJoin := filters.PersonID > 0
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter
|
||||
// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into
|
||||
// the WHERE clause, and is absent otherwise. Guards against a future refactor
|
||||
// of buildBrowsePlan silently dropping the condition.
|
||||
func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) {
|
||||
const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL"
|
||||
repo := &BrowseRepository{}
|
||||
|
||||
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true})
|
||||
if err != nil || earlyEmpty {
|
||||
t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty)
|
||||
}
|
||||
if !strings.Contains(plan.whereClause, predicate) {
|
||||
t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
|
||||
plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"})
|
||||
if err != nil {
|
||||
t.Fatalf("buildBrowsePlan err=%v", err)
|
||||
}
|
||||
if strings.Contains(plan.whereClause, predicate) {
|
||||
t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
}
|
||||
@@ -389,11 +389,12 @@ func normalizeCatalogMatch(raw string) string {
|
||||
}
|
||||
|
||||
func parseCatalogMediaScope(raw string) string {
|
||||
scope := strings.ToLower(strings.TrimSpace(raw))
|
||||
if scope != "" && IsValidMediaScope(scope) {
|
||||
return scope
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "movie", "series", "episode", "audiobook":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultCatalogQuerySort(searchQuery string) QuerySort {
|
||||
|
||||
@@ -1086,8 +1086,12 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
|
||||
}
|
||||
|
||||
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
|
||||
if !IsValidMediaScope(def.MediaScope) {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'", ErrInvalidCatalogRequest)
|
||||
if def.MediaScope != "" &&
|
||||
def.MediaScope != "movie" &&
|
||||
def.MediaScope != "series" &&
|
||||
def.MediaScope != "episode" &&
|
||||
def.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
|
||||
}
|
||||
if def.Match != "" && def.Match != "all" && def.Match != "any" {
|
||||
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
|
||||
@@ -1603,7 +1607,12 @@ func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter,
|
||||
MaxContentRating: access.MaxContentRating,
|
||||
}
|
||||
|
||||
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
|
||||
var itemTypes []string
|
||||
if req.Query.MediaScope != "" {
|
||||
itemTypes = []string{req.Query.MediaScope}
|
||||
}
|
||||
|
||||
return searchAccess, itemTypes, false
|
||||
}
|
||||
|
||||
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
|
||||
@@ -1613,9 +1622,7 @@ func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilter
|
||||
}
|
||||
|
||||
filters := BrowseFilters{
|
||||
// BrowseFilters.Type accepts a comma-separated type list, so group
|
||||
// scopes like "video" expand here rather than leaking downstream.
|
||||
Type: strings.Join(MediaScopeItemTypes(req.Query.MediaScope), ","),
|
||||
Type: req.Query.MediaScope,
|
||||
NamePrefix: req.NamePrefix,
|
||||
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
|
||||
MaxContentRating: access.MaxContentRating,
|
||||
@@ -1756,7 +1763,7 @@ func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*model
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
|
||||
if def.MediaScope != "" && !strings.EqualFold(strings.TrimSpace(item.Type), def.MediaScope) {
|
||||
continue
|
||||
}
|
||||
if catalogDefinitionMatchesItem(item, def) {
|
||||
|
||||
@@ -1061,10 +1061,6 @@ func firstNonEmptyString(values []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *DetailService) presignAudiobookPosterURL(ctx context.Context, posterPath string) string {
|
||||
return s.PresignImageURL(ctx, posterPath, "poster", "")
|
||||
}
|
||||
|
||||
func appendAudiobookItemAccessConditions(
|
||||
alias string,
|
||||
filter AccessFilter,
|
||||
@@ -1150,7 +1146,7 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
@@ -1209,7 +1205,7 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
@@ -1270,7 +1266,7 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
|
||||
item.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
|
||||
item.PosterURL = s.PresignURL(ctx, poster, "featured")
|
||||
entries = append(entries, item)
|
||||
}
|
||||
if len(entries) < 2 {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -42,37 +40,3 @@ func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresignAudiobookPosterURLUsesPosterVariant(t *testing.T) {
|
||||
resolver := &recordingCatalogImageResolver{}
|
||||
detail := &DetailService{}
|
||||
detail.SetImageResolver(resolver)
|
||||
|
||||
got := detail.presignAudiobookPosterURL(context.Background(), "local/audiobooks/book/poster/original.webp")
|
||||
|
||||
if !strings.Contains(got, "/w500.webp") {
|
||||
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
|
||||
}
|
||||
if resolver.variant != "featured" {
|
||||
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCatalogImageResolver struct {
|
||||
path string
|
||||
variant string
|
||||
}
|
||||
|
||||
func (r *recordingCatalogImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
|
||||
r.path = path
|
||||
r.variant = variant
|
||||
return "resolved://" + path
|
||||
}
|
||||
|
||||
func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
|
||||
out := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
out[path] = r.ResolveImageURL(context.Background(), path, variant)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -35,26 +35,6 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
|
||||
return &ItemRepository{pool: pool}
|
||||
}
|
||||
|
||||
// GetPosterPath returns the current poster path for a media item. Missing or
|
||||
// NULL poster values are returned as an empty string.
|
||||
func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (string, error) {
|
||||
if r == nil || r.pool == nil {
|
||||
return "", ErrItemNotFound
|
||||
}
|
||||
var posterPath string
|
||||
if err := r.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(poster_path, '')
|
||||
FROM media_items
|
||||
WHERE content_id = $1
|
||||
`, contentID).Scan(&posterPath); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrItemNotFound
|
||||
}
|
||||
return "", fmt.Errorf("getting media item poster path: %w", err)
|
||||
}
|
||||
return posterPath, nil
|
||||
}
|
||||
|
||||
// overviewMatchFloor is the minimum ts_rank_cd score an overview-only match
|
||||
// must achieve to be returned when no title FTS match exists in the candidate
|
||||
// set. The overview tsvector is built without setweight(), so PostgreSQL's
|
||||
@@ -65,87 +45,60 @@ func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (s
|
||||
// incidental one-mention hits that flooded results before.
|
||||
const overviewMatchFloor = 0.15
|
||||
|
||||
// itemColumnNames lists, in scan order, every column selected by media_items
|
||||
// item queries. Shared by itemColumns, qualifiedItemColumns,
|
||||
// qualifiedListItemColumns, and qualifiedItemColumnRefs so the select lists
|
||||
// can never drift from each other or from scanItem.
|
||||
var itemColumnNames = []string{
|
||||
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
|
||||
"content_rating", "runtime", "overview", "tagline",
|
||||
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"show_status",
|
||||
"matched_at", "last_refreshed", "refresh_failures",
|
||||
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// nullableStringItemColumns are media_items columns that may hold NULL but
|
||||
// scan into plain (non-pointer) string fields on models.MediaItem, so select
|
||||
// lists coalesce them to ”.
|
||||
var nullableStringItemColumns = map[string]bool{
|
||||
"poster_path": true,
|
||||
"poster_thumbhash": true,
|
||||
"backdrop_path": true,
|
||||
"backdrop_thumbhash": true,
|
||||
"logo_path": true,
|
||||
"metadata_s3_path": true,
|
||||
"metadata_etag": true,
|
||||
}
|
||||
|
||||
// itemColumnExpr renders one select-list entry for col, qualified with alias
|
||||
// when non-empty. Nullable string columns are coalesced to ” and aliased
|
||||
// back to their own name so queries that wrap the select list in a CTE or
|
||||
// subquery can still reference the column by name.
|
||||
func itemColumnExpr(alias, col string) string {
|
||||
qualified := col
|
||||
if alias != "" {
|
||||
qualified = alias + "." + col
|
||||
}
|
||||
if nullableStringItemColumns[col] {
|
||||
return fmt.Sprintf("COALESCE(%s, '') AS %s", qualified, col)
|
||||
}
|
||||
return qualified
|
||||
}
|
||||
|
||||
func joinItemColumns(alias string) string {
|
||||
exprs := make([]string, len(itemColumnNames))
|
||||
for i, col := range itemColumnNames {
|
||||
exprs[i] = itemColumnExpr(alias, col)
|
||||
}
|
||||
return strings.Join(exprs, ", ")
|
||||
}
|
||||
|
||||
// itemColumns is the list of columns returned by all SELECT queries on media_items.
|
||||
var itemColumns = joinItemColumns("")
|
||||
const itemColumns = `content_id, type, title, sort_title, default_metadata_language, original_title, year, genres,
|
||||
content_rating, runtime, overview, tagline,
|
||||
rating_imdb, rating_tmdb, rating_rt_critic, rating_rt_audience,
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
||||
metadata_s3_path, metadata_etag, season_count,
|
||||
studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, air_timezone,
|
||||
show_status,
|
||||
matched_at, last_refreshed, refresh_failures,
|
||||
episode_metadata_incomplete, episode_metadata_last_checked_at, locked_fields, status, created_at, updated_at`
|
||||
|
||||
func qualifiedItemColumns(alias string) string {
|
||||
return joinItemColumns(alias)
|
||||
}
|
||||
|
||||
// qualifiedItemColumnRefs renders plain alias-qualified column references
|
||||
// without COALESCE or AS aliases, for contexts like GROUP BY where output
|
||||
// aliases are invalid.
|
||||
func qualifiedItemColumnRefs(alias string) string {
|
||||
refs := make([]string, len(itemColumnNames))
|
||||
for i, col := range itemColumnNames {
|
||||
refs[i] = alias + "." + col
|
||||
cols := []string{
|
||||
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
|
||||
"content_rating", "runtime", "overview", "tagline",
|
||||
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"show_status",
|
||||
"matched_at", "last_refreshed", "refresh_failures",
|
||||
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
|
||||
}
|
||||
return strings.Join(refs, ", ")
|
||||
prefixed := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
prefixed[i] = alias + "." + col
|
||||
}
|
||||
return strings.Join(prefixed, ", ")
|
||||
}
|
||||
|
||||
func qualifiedListItemColumns(alias string) string {
|
||||
exprs := make([]string, len(itemColumnNames))
|
||||
for i, col := range itemColumnNames {
|
||||
cols := []string{
|
||||
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
|
||||
"content_rating", "runtime", "overview", "tagline",
|
||||
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
||||
"imdb_id", "tmdb_id", "tvdb_id",
|
||||
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
||||
"metadata_s3_path", "metadata_etag", "season_count",
|
||||
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
|
||||
"show_status",
|
||||
"matched_at", "last_refreshed", "refresh_failures",
|
||||
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
|
||||
}
|
||||
prefixed := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
if col == "last_air_date" {
|
||||
exprs[i] = effectiveLastAirDateExpr(alias) + " AS last_air_date"
|
||||
prefixed[i] = effectiveLastAirDateExpr(alias)
|
||||
continue
|
||||
}
|
||||
exprs[i] = itemColumnExpr(alias, col)
|
||||
prefixed[i] = alias + "." + col
|
||||
}
|
||||
return strings.Join(exprs, ", ")
|
||||
return strings.Join(prefixed, ", ")
|
||||
}
|
||||
|
||||
// scanItem scans a single row into a *models.MediaItem.
|
||||
@@ -1003,12 +956,8 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
|
||||
)
|
||||
|
||||
// Use qualified column names inside the CTE to avoid ambiguity when
|
||||
// the FROM clause includes a JOIN to media_item_libraries. The select
|
||||
// list aliases coalesced columns back to their own names (poster_path
|
||||
// etc.) so the outer query can re-reference them; GROUP BY needs the
|
||||
// raw references because output aliases are invalid there.
|
||||
// the FROM clause includes a JOIN to media_item_libraries.
|
||||
qualifiedCols := qualifiedItemColumns("mi")
|
||||
groupByCols := qualifiedItemColumnRefs("mi")
|
||||
scoredCTE := fmt.Sprintf(`
|
||||
WITH scored AS (
|
||||
SELECT
|
||||
@@ -1035,7 +984,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
|
||||
%s
|
||||
GROUP BY %s
|
||||
)
|
||||
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, groupByCols)
|
||||
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, qualifiedCols)
|
||||
|
||||
// COUNT(*) OVER () runs after the GROUP BY in the scored CTE collapses
|
||||
// duplicates from the library JOIN, so the window count preserves the
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -368,59 +367,3 @@ func TestItemRepo_Search_NormalizesTsqueryInput(t *testing.T) {
|
||||
t.Fatalf("overview arm should NOT wrap $1 in normalize_search_text; got:\n%s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemRepo_Search_ScoredCTEExposesItemColumnNames guards the contract
|
||||
// between the scored CTE and the outer SELECT in buildSearchSQL. The CTE
|
||||
// projects qualifiedItemColumns("mi") and the outer query re-selects those
|
||||
// columns by name via itemColumns, so every entry must expose its own column
|
||||
// name as the output name. Postgres names an unaliased expression like
|
||||
// COALESCE(mi.poster_path, ”) "coalesce", which breaks the outer reference
|
||||
// with SQLSTATE 42703 (column "poster_path" does not exist) — exactly how
|
||||
// search returned 500s when the nullable-string COALESCE wrappers first
|
||||
// landed without AS aliases.
|
||||
func TestItemRepo_Search_ScoredCTEExposesItemColumnNames(t *testing.T) {
|
||||
exposed := map[string]bool{}
|
||||
for _, part := range splitTopLevelSQLCommas(qualifiedItemColumns("mi")) {
|
||||
exposed[sqlOutputColumnName(part)] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, col := range itemColumnNames {
|
||||
name := trailingSQLIdent(col)
|
||||
if !exposed[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Fatalf("qualifiedItemColumns(\"mi\") does not expose output columns %v; "+
|
||||
"alias each expression back to its column name (e.g. COALESCE(mi.x, '') AS x) "+
|
||||
"or the search CTE's outer SELECT fails with SQLSTATE 42703", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemRepo_Search_GroupByHasNoOutputAliases asserts the scored CTE's
|
||||
// GROUP BY uses raw column references: AS aliases are valid in a select list
|
||||
// but are a syntax error inside GROUP BY, so the CTE must not reuse the
|
||||
// aliased projection there.
|
||||
func TestItemRepo_Search_GroupByHasNoOutputAliases(t *testing.T) {
|
||||
repo := &ItemRepository{}
|
||||
dataSQL, countSQL, _ := repo.buildSearchSQL("avatar", []string{"movie"}, 20, 0, AccessFilter{})
|
||||
|
||||
for _, sql := range []string{dataSQL, countSQL} {
|
||||
idx := strings.Index(sql, "GROUP BY")
|
||||
if idx < 0 {
|
||||
t.Fatalf("expected GROUP BY in scored CTE; got:\n%s", sql)
|
||||
}
|
||||
clause := sql[idx:]
|
||||
if end := strings.Index(clause, ")"); end >= 0 {
|
||||
clause = clause[:end]
|
||||
}
|
||||
if strings.Contains(clause, " AS ") {
|
||||
t.Fatalf("GROUP BY must not contain output aliases; got:\n%s", clause)
|
||||
}
|
||||
if strings.Contains(clause, "COALESCE") {
|
||||
t.Fatalf("GROUP BY should group by raw columns, not COALESCE expressions; got:\n%s", clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMediaScopeItemTypes pins the expansion of group scopes: "video" covers
|
||||
// the video-side media_items types so search/browse can offer a
|
||||
// "Movies & Series vs Audiobooks" split without enumerating types per caller.
|
||||
func TestMediaScopeItemTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
scope string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"movie", []string{"movie"}},
|
||||
{"audiobook", []string{"audiobook"}},
|
||||
{"video", []string{"movie", "series"}},
|
||||
{" Video ", []string{"movie", "series"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MediaScopeItemTypes(tc.scope); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("MediaScopeItemTypes(%q) = %v, want %v", tc.scope, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaScopeMatchesItemType(t *testing.T) {
|
||||
cases := []struct {
|
||||
scope string
|
||||
itemType string
|
||||
want bool
|
||||
}{
|
||||
{"", "audiobook", true},
|
||||
{"video", "movie", true},
|
||||
{"video", "series", true},
|
||||
{"video", "audiobook", false},
|
||||
{"audiobook", "audiobook", true},
|
||||
{"movie", "series", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MediaScopeMatchesItemType(tc.scope, tc.itemType); got != tc.want {
|
||||
t.Errorf("MediaScopeMatchesItemType(%q, %q) = %v, want %v", tc.scope, tc.itemType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCatalogRequest_VideoMediaScope asserts ?type=video parses into the
|
||||
// query definition's media scope, and that catalogSearchAccess expands it to
|
||||
// the video item types for the direct search path.
|
||||
func TestParseCatalogRequest_VideoMediaScope(t *testing.T) {
|
||||
req, err := ParseCatalogRequest(url.Values{
|
||||
"source": {"query"},
|
||||
"q": {"the rookie"},
|
||||
"type": {"video"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCatalogRequest: %v", err)
|
||||
}
|
||||
if req.Query.MediaScope != "video" {
|
||||
t.Fatalf("expected media scope video, got %q", req.Query.MediaScope)
|
||||
}
|
||||
|
||||
_, itemTypes, earlyEmpty := catalogSearchAccess(req, AccessFilter{})
|
||||
if earlyEmpty {
|
||||
t.Fatal("unexpected early empty")
|
||||
}
|
||||
if !reflect.DeepEqual(itemTypes, []string{"movie", "series"}) {
|
||||
t.Fatalf("expected video scope to expand to movie+series, got %v", itemTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewPage_VideoScopeUsesTypeAny asserts the preview/query-executor
|
||||
// path renders a multi-type condition for the video group scope.
|
||||
func TestPreviewPage_VideoScopeUsesTypeAny(t *testing.T) {
|
||||
sql, args, err := (&QueryExecutor{}).buildPreviewPageSQL(
|
||||
QueryDefinition{
|
||||
MediaScope: "video",
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
},
|
||||
AccessFilter{},
|
||||
20,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPreviewPageSQL error: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, "mi.type = ANY(") {
|
||||
t.Fatalf("expected mi.type = ANY(...) for video scope, got %s", sql)
|
||||
}
|
||||
found := false
|
||||
for _, arg := range args {
|
||||
if types, ok := arg.([]string); ok && reflect.DeepEqual(types, []string{"movie", "series"}) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected movie+series type arg, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryDefinitionValidate_VideoScope asserts "video" passes definition
|
||||
// validation alongside the single-type scopes.
|
||||
func TestQueryDefinitionValidate_VideoScope(t *testing.T) {
|
||||
def := QueryDefinition{MediaScope: "video"}
|
||||
if err := def.Validate(); err != nil {
|
||||
t.Fatalf("expected video media scope to validate, got %v", err)
|
||||
}
|
||||
bad := QueryDefinition{MediaScope: "podcast"}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Fatal("expected invalid media scope to fail validation")
|
||||
}
|
||||
}
|
||||
@@ -104,53 +104,6 @@ type QueryDefinition struct {
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// MediaScopeVideo is the group scope covering all video-side item types. It
|
||||
// is accepted anywhere a single-type media scope is and expands to the
|
||||
// underlying media_items.type values via MediaScopeItemTypes.
|
||||
const MediaScopeVideo = "video"
|
||||
|
||||
// IsValidMediaScope reports whether scope (already normalized to lowercase)
|
||||
// is an accepted media_scope value. Empty means unscoped and is valid.
|
||||
func IsValidMediaScope(scope string) bool {
|
||||
switch scope {
|
||||
case "", "movie", "series", "episode", "audiobook", MediaScopeVideo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MediaScopeItemTypes expands a media scope into the media_items.type values
|
||||
// it covers. Single-type scopes map to themselves; the empty scope returns
|
||||
// nil (unscoped).
|
||||
func MediaScopeItemTypes(scope string) []string {
|
||||
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||
switch scope {
|
||||
case "":
|
||||
return nil
|
||||
case MediaScopeVideo:
|
||||
return []string{"movie", "series"}
|
||||
default:
|
||||
return []string{scope}
|
||||
}
|
||||
}
|
||||
|
||||
// MediaScopeMatchesItemType reports whether an item type falls inside scope.
|
||||
// An empty scope matches every type.
|
||||
func MediaScopeMatchesItemType(scope, itemType string) bool {
|
||||
types := MediaScopeItemTypes(scope)
|
||||
if len(types) == 0 {
|
||||
return true
|
||||
}
|
||||
itemType = strings.ToLower(strings.TrimSpace(itemType))
|
||||
for _, t := range types {
|
||||
if t == itemType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultSmartCollectionItemLimit = 100
|
||||
MaxSmartCollectionItemLimit = 500
|
||||
@@ -229,8 +182,12 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
}
|
||||
}
|
||||
|
||||
if !IsValidMediaScope(normalized.MediaScope) {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'")
|
||||
if normalized.MediaScope != "" &&
|
||||
normalized.MediaScope != "movie" &&
|
||||
normalized.MediaScope != "series" &&
|
||||
normalized.MediaScope != "episode" &&
|
||||
normalized.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
|
||||
@@ -282,14 +282,8 @@ func (e *QueryExecutor) buildPreviewPagePlan(
|
||||
conditions = append(conditions, filterWhere)
|
||||
}
|
||||
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
|
||||
scopeTypes := MediaScopeItemTypes(def.MediaScope)
|
||||
if len(scopeTypes) == 1 {
|
||||
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
|
||||
args = append(args, scopeTypes[0])
|
||||
} else {
|
||||
conditions = append(conditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
|
||||
args = append(args, scopeTypes)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
|
||||
args = append(args, def.MediaScope)
|
||||
argIdx++
|
||||
}
|
||||
libScopeWhere, libScopeArgs, hasLibraryScope := "", []any(nil), false
|
||||
|
||||
@@ -355,9 +355,9 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
}
|
||||
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
|
||||
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps. Docker
|
||||
// deployments publish :13378, so listen by default unless disabled.
|
||||
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", true)
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
|
||||
// listener off unless ABS compatibility is explicitly enabled.
|
||||
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -35,15 +35,7 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
if cfg.AudiobookshelfCompat.Listen != ":13378" {
|
||||
t.Fatalf("default audiobooks listener = %q, want default :13378", cfg.AudiobookshelfCompat.Listen)
|
||||
}
|
||||
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ func ExtractToken(r *http.Request) (string, bool) {
|
||||
if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" {
|
||||
return token, true
|
||||
}
|
||||
// Case-insensitive: Jellyfin clients vary the casing (api_key / Api_Key / API_KEY).
|
||||
if token := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("api_key")); token != "" {
|
||||
if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" {
|
||||
return token, true
|
||||
}
|
||||
|
||||
@@ -186,12 +185,7 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
|
||||
// no auth header or api_key. Resolve the negotiated session's
|
||||
// CompatToken — which for an API-key stream is itself the sa_ key,
|
||||
// so it must go through the same session-or-API-key resolution.
|
||||
//
|
||||
// The lookup must be case-insensitive: Wholphin's jellyfin-sdk-kotlin
|
||||
// builds its own direct-play URL with a lowercase "playSessionId"
|
||||
// (and no api_key / auth header), so a case-sensitive match would
|
||||
// miss it and 401 the stream — forcing a needless transcode fallback.
|
||||
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
|
||||
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
|
||||
if playSession, found := playbackStore.Get(playSessionID); found {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
|
||||
serveWithSession(next, w, r, session)
|
||||
|
||||
@@ -119,60 +119,6 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
sessions := NewSessionStore(30*24*time.Hour, clock)
|
||||
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
|
||||
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
|
||||
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
|
||||
|
||||
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
wantCode int
|
||||
}{
|
||||
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
|
||||
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
|
||||
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
|
||||
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
|
||||
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
|
||||
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
|
||||
{"no auth at all", "static=true", http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gotSession := false
|
||||
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if SessionFromContext(r.Context()) != nil {
|
||||
gotSession = true
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
|
||||
}
|
||||
if tc.wantCode == http.StatusOK && !gotSession {
|
||||
t.Fatal("expected authenticated session in context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
|
||||
for _, key := range []string{"api_key", "Api_Key", "API_KEY"} {
|
||||
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
|
||||
if got, ok := ExtractToken(req); !ok || got != "tok123" {
|
||||
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
|
||||
authn := newAdminAPIKeyAuthForTest(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
|
||||
@@ -226,7 +226,6 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
|
||||
Limit: fetchLimit,
|
||||
MaxLimit: compatBrowseMaxLimit,
|
||||
Offset: requestedOffset,
|
||||
RequireBackdrop: parseBool(params.Get("require_backdrop"), false),
|
||||
}
|
||||
|
||||
var collected []upstreamListItem
|
||||
|
||||
@@ -48,7 +48,6 @@ type baseItemDTO struct {
|
||||
ProviderIDs map[string]string `json:"ProviderIds,omitempty"`
|
||||
ProductionLocations []string `json:"ProductionLocations,omitempty"`
|
||||
ImageTags map[string]string `json:"ImageTags"`
|
||||
PrimaryImageItemID string `json:"PrimaryImageItemId,omitempty"`
|
||||
BackdropImageTags []string `json:"BackdropImageTags,omitempty"`
|
||||
PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"`
|
||||
ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"`
|
||||
@@ -74,7 +73,6 @@ type baseItemDTO struct {
|
||||
SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"`
|
||||
MovieCount int `json:"MovieCount,omitempty"`
|
||||
SeriesCount int `json:"SeriesCount,omitempty"`
|
||||
SeasonCount int `json:"SeasonCount,omitempty"`
|
||||
EpisodeCount int `json:"EpisodeCount,omitempty"`
|
||||
LockedFields []string `json:"LockedFields,omitempty"`
|
||||
LockData bool `json:"LockData,omitempty"`
|
||||
|
||||
@@ -14,18 +14,16 @@ import (
|
||||
|
||||
// displayPreferencesDTO mirrors Jellyfin's DisplayPreferences response.
|
||||
type displayPreferencesDTO struct {
|
||||
ID string `json:"Id"`
|
||||
SortBy string `json:"SortBy"`
|
||||
SortOrder string `json:"SortOrder"`
|
||||
RememberIndexing bool `json:"RememberIndexing"`
|
||||
RememberSorting bool `json:"RememberSorting"`
|
||||
ScrollDirection string `json:"ScrollDirection"`
|
||||
ShowBackdrop bool `json:"ShowBackdrop"`
|
||||
ShowSidebar bool `json:"ShowSidebar"`
|
||||
PrimaryImageHeight int `json:"PrimaryImageHeight"`
|
||||
PrimaryImageWidth int `json:"PrimaryImageWidth"`
|
||||
Client string `json:"Client"`
|
||||
CustomPrefs map[string]string `json:"CustomPrefs"`
|
||||
ID string `json:"Id"`
|
||||
SortBy string `json:"SortBy"`
|
||||
SortOrder string `json:"SortOrder"`
|
||||
RememberIndexing bool `json:"RememberIndexing"`
|
||||
RememberSorting bool `json:"RememberSorting"`
|
||||
ScrollDirection string `json:"ScrollDirection"`
|
||||
ShowBackdrop bool `json:"ShowBackdrop"`
|
||||
ShowSidebar bool `json:"ShowSidebar"`
|
||||
Client string `json:"Client"`
|
||||
CustomPrefs map[string]string `json:"CustomPrefs"`
|
||||
}
|
||||
|
||||
// DisplayPreferencesHandler serves Jellyfin display preferences endpoints,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultDisplayPreferencesIncludesRequiredImageDimensions(t *testing.T) {
|
||||
dto := defaultDisplayPreferences("default", "Wholphin")
|
||||
|
||||
body, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal default display preferences: %v", err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
t.Fatalf("unmarshal default display preferences: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := raw["PrimaryImageHeight"]; !ok {
|
||||
t.Fatal("PrimaryImageHeight missing from display preferences JSON")
|
||||
}
|
||||
if _, ok := raw["PrimaryImageWidth"]; !ok {
|
||||
t.Fatal("PrimaryImageWidth missing from display preferences JSON")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
type ImagesHandler struct {
|
||||
content ContentService
|
||||
codec *ResourceIDCodec
|
||||
httpClient *http.Client
|
||||
sessions *SessionStore
|
||||
images *ImageCache
|
||||
personRepo *catalog.PersonRepository
|
||||
@@ -29,7 +29,6 @@ type ImagesHandler struct {
|
||||
posterSigner LibraryPosterPresigner
|
||||
presignTTL time.Duration
|
||||
imageTags *imageTagSigner
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type imageItemRepository interface {
|
||||
@@ -49,14 +48,15 @@ type imageFolderRepository interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
// NewImagesHandler creates a Jellyfin-compatible image route handler.
|
||||
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, 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, httpClient *http.Client) *ImagesHandler {
|
||||
// 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, 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
|
||||
}
|
||||
return &ImagesHandler{
|
||||
content: content,
|
||||
codec: codec,
|
||||
httpClient: httpClient,
|
||||
sessions: sessions,
|
||||
images: images,
|
||||
personRepo: personRepo,
|
||||
@@ -69,7 +69,6 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, sessions *
|
||||
posterSigner: posterSigner,
|
||||
presignTTL: presignTTL,
|
||||
imageTags: newImageTagSigner(imageTagSecret),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +80,6 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
|
||||
imageType := chiURLParam(r, "imageType")
|
||||
imageSize := compatRequestImageSize(r, imageType)
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
if canonicalRouteID, ok := canonicalCompatImageRouteID(h.codec, routeID); ok {
|
||||
routeID = canonicalRouteID
|
||||
r = withCompatImageProxyRouteRequest(r)
|
||||
}
|
||||
if tag != "" {
|
||||
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
|
||||
if err != nil {
|
||||
@@ -93,15 +88,15 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
if ok {
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
|
||||
h.serveImageURL(w, r, imageURL.URL)
|
||||
h.proxyImageURL(w, r, imageURL.URL)
|
||||
return
|
||||
}
|
||||
if imageURL, ok := h.images.LookupTag(tag); ok {
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +133,7 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
}
|
||||
|
||||
// handlePersonImage serves person photo images.
|
||||
@@ -164,7 +159,7 @@ func (h *ImagesHandler) handlePersonImage(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) resolveItemImageURL(ctx context.Context, session *Session, contentID, imageType string, r *http.Request) (catalog.ResolvedImageURL, error) {
|
||||
@@ -419,45 +414,18 @@ func firstResolvedImageURL(values ...catalog.ResolvedImageURL) catalog.ResolvedI
|
||||
return catalog.ResolvedImageURL{}
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
|
||||
if shouldProxyCompatImageRequest(r) {
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
h.redirectImageURL(w, r, imageURL)
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) redirectImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
|
||||
if _, err := parseRemoteImageURL(imageURL); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
}
|
||||
|
||||
// Do not let clients cache the temporary redirect itself. The object-store
|
||||
// response can still carry its own cache headers after the client follows it.
|
||||
setCompatImageRouteNoStore(w.Header())
|
||||
http.Redirect(w, r, imageURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
|
||||
target, err := parseRemoteImageURL(imageURL)
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
for _, header := range []string{"If-None-Match", "If-Modified-Since"} {
|
||||
if value := r.Header.Get(header); value != "" {
|
||||
req.Header.Set(header, value)
|
||||
}
|
||||
}
|
||||
copyConditionalImageRequestHeaders(req.Header, r.Header)
|
||||
|
||||
client := h.httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
@@ -465,15 +433,6 @@ func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, im
|
||||
proxyImage(w, resp)
|
||||
}
|
||||
|
||||
func parseRemoteImageURL(imageURL string) (*url.URL, error) {
|
||||
target, err := url.Parse(imageURL)
|
||||
if err != nil || target.Scheme == "" || target.Host == "" ||
|
||||
(target.Scheme != "http" && target.Scheme != "https") {
|
||||
return nil, errors.New("invalid remote image URL")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// HandleUserImage returns a deterministic placeholder avatar.
|
||||
func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
|
||||
@@ -140,8 +140,6 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleSearchItems(w, r, session, query)
|
||||
case query.isFavorite:
|
||||
h.handleFavoriteItems(w, r, session, query)
|
||||
case isSeasonChildItemsQuery(query):
|
||||
h.handleSeasonChildItems(w, r, session, query)
|
||||
case query.parentLibraryID == 0 && len(query.itemTypes) == 0:
|
||||
// No ParentId and no type filter: return top-level library views.
|
||||
// Jellyfin clients (e.g. Findroid "My Media") call GET /Items?userId=...
|
||||
@@ -152,18 +150,6 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func isSeasonChildItemsQuery(query itemsQuery) bool {
|
||||
if query.parentItemID == "" {
|
||||
return false
|
||||
}
|
||||
for _, itemType := range query.itemTypes {
|
||||
if itemType == "season" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleItem serves GET /Items/{id}.
|
||||
func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
@@ -223,7 +209,6 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
|
||||
browsableSeasons := filterBrowsableSeasons(seasons)
|
||||
dto.ChildCount = len(browsableSeasons)
|
||||
dto.RecursiveItemCount = len(browsableSeasons)
|
||||
dto.SeasonCount = len(browsableSeasons)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
|
||||
@@ -934,35 +919,9 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
writeCompatUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
h.writeSeasonItemsResponse(w, r, session, seriesID, seasons, query, false)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) handleSeasonChildItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
|
||||
seasons, err := h.content.ListSeasons(r.Context(), session, query.parentItemID, nil)
|
||||
if err != nil {
|
||||
writeCompatUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
h.writeSeasonItemsResponse(w, r, session, query.parentItemID, seasons, query, true)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.Request, session *Session, seriesID string, seasons []upstreamSeason, query itemsQuery, page bool) {
|
||||
seasons = filterBrowsableSeasons(seasons)
|
||||
h.rememberSeasonImages(seasons, seriesID)
|
||||
|
||||
total := len(seasons)
|
||||
if page {
|
||||
start := query.startIndex
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := total
|
||||
if query.limit > 0 && start+query.limit < end {
|
||||
end = start + query.limit
|
||||
}
|
||||
seasons = seasons[start:end]
|
||||
}
|
||||
|
||||
favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons))
|
||||
if err != nil {
|
||||
writeCompatUpstreamError(w, err)
|
||||
@@ -985,16 +944,10 @@ func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
items = append(items, h.mapper.seasonFromUpstream(season, seriesID, favorites[season.ContentID]))
|
||||
}
|
||||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||||
|
||||
startIndex := 0
|
||||
if page {
|
||||
startIndex = query.startIndex
|
||||
}
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: items,
|
||||
TotalRecordCount: total,
|
||||
StartIndex: startIndex,
|
||||
TotalRecordCount: len(items),
|
||||
StartIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -20,10 +19,7 @@ import (
|
||||
type countingContentService struct {
|
||||
episodeDetail *upstreamItemDetail
|
||||
seriesDetail *upstreamItemDetail
|
||||
seasons []upstreamSeason
|
||||
getItemDetailCalls int
|
||||
listSeasonsCalls int
|
||||
listSeasonsSeries string
|
||||
}
|
||||
|
||||
func (s *countingContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) {
|
||||
@@ -54,15 +50,8 @@ func (s *countingContentService) SearchItems(context.Context, *Session, string,
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (s *countingContentService) ListSeasons(_ context.Context, _ *Session, seriesID string, _ *int) ([]upstreamSeason, error) {
|
||||
s.listSeasonsCalls++
|
||||
s.listSeasonsSeries = seriesID
|
||||
if s.seasons == nil {
|
||||
panic("unused")
|
||||
}
|
||||
out := make([]upstreamSeason, len(s.seasons))
|
||||
copy(out, s.seasons)
|
||||
return out, nil
|
||||
func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (s *countingContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
|
||||
@@ -81,65 +70,6 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
seriesContentID := "series-1"
|
||||
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
|
||||
contentSvc := &countingContentService{
|
||||
seasons: []upstreamSeason{
|
||||
{ContentID: "season-1", SeasonNumber: 1, Title: "Season 1", EpisodeCount: 10},
|
||||
{ContentID: "season-2", SeasonNumber: 2, Title: "Season 2", EpisodeCount: 8},
|
||||
},
|
||||
}
|
||||
|
||||
h := &ItemsHandler{
|
||||
content: contentSvc,
|
||||
userData: &mockUserDataService{},
|
||||
codec: codec,
|
||||
mapper: newMapper(codec, &config.Config{}),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/Users/test/Items?ParentId="+encodedSeriesID+
|
||||
"&IncludeItemTypes=Season&Recursive=false&SortBy=IndexNumber&SortOrder=Ascending"+
|
||||
"&Fields=PrimaryImageAspectRatio,CanDelete&StartIndex=1&Limit=1", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
|
||||
StreamAppUserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleItems(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if contentSvc.listSeasonsCalls != 1 || contentSvc.listSeasonsSeries != seriesContentID {
|
||||
t.Fatalf("ListSeasons calls = %d for %q, want 1 for %q",
|
||||
contentSvc.listSeasonsCalls, contentSvc.listSeasonsSeries, seriesContentID)
|
||||
}
|
||||
|
||||
var result queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if result.TotalRecordCount != 2 || result.StartIndex != 1 {
|
||||
t.Fatalf("TotalRecordCount/StartIndex = %d/%d, want 2/1",
|
||||
result.TotalRecordCount, result.StartIndex)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("len(Items) = %d, want 1", len(result.Items))
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.Type != "Season" || item.Name != "Season 2" {
|
||||
t.Fatalf("item = {%q %q}, want Season/Season 2", item.Type, item.Name)
|
||||
}
|
||||
if item.ParentID != encodedSeriesID || item.SeriesID != encodedSeriesID {
|
||||
t.Fatalf("ParentID/SeriesID = %q/%q, want %q/%q",
|
||||
item.ParentID, item.SeriesID, encodedSeriesID, encodedSeriesID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -439,7 +439,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
|
||||
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source)
|
||||
|
||||
@@ -21,7 +21,6 @@ const (
|
||||
EncodedIDGenre EncodedIDType = 6
|
||||
EncodedIDStudio EncodedIDType = 7
|
||||
EncodedIDPerson EncodedIDType = 8
|
||||
EncodedIDImageProxy EncodedIDType = 9
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -142,21 +141,6 @@ func (c *ResourceIDCodec) LookupMediaSourceOwner(fileID int64) (string, bool) {
|
||||
return contentID, ok
|
||||
}
|
||||
|
||||
// mediaSourceIDsEqual reports whether two media-source IDs refer to the same
|
||||
// source, tolerating UUID format differences. Silo exposes the canonical
|
||||
// dashed compat UUID (e.g. "03000000-0000-0000-0000-00000019e8c2"), but some
|
||||
// Jellyfin clients (e.g. Wholphin) echo it back in the compact 32-char hex
|
||||
// form ("0300000000000000000000000019e8c2"). Both parse to the same UUID, so
|
||||
// matching must compare the parsed values rather than the raw strings.
|
||||
func mediaSourceIDsEqual(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
ua, errA := uuid.Parse(a)
|
||||
ub, errB := uuid.Parse(b)
|
||||
return errA == nil && errB == nil && ua == ub
|
||||
}
|
||||
|
||||
// DecodeID unpacks a compat UUID into its original numeric value.
|
||||
func DecodeID(raw string) (DecodedID, error) {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMediaSourceIDsEqual(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "identical dashed",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "03000000-0000-0000-0000-00000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dashed vs compact (Wholphin)",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "0300000000000000000000000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compact vs dashed",
|
||||
a: "0300000000000000000000000019e8c2",
|
||||
b: "03000000-0000-0000-0000-00000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive hex",
|
||||
a: "03000000-0000-0000-0000-00000019E8C2",
|
||||
b: "0300000000000000000000000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different uuids",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "0300000000000000000000000019e8c3",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-uuid falls back to exact match",
|
||||
a: "abc",
|
||||
b: "abc",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-uuid mismatch",
|
||||
a: "abc",
|
||||
b: "def",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mediaSourceIDsEqual(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("mediaSourceIDsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
|
||||
return "", false
|
||||
}
|
||||
|
||||
if tag = canonicalCompatImageTag(tag); tag != "" {
|
||||
if tag = strings.TrimSpace(tag); tag != "" {
|
||||
return c.LookupTag(tag)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (c *ImageCache) LookupTag(tag string) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.lookupTag(canonicalCompatImageTag(tag))
|
||||
return c.lookupTag(strings.TrimSpace(tag))
|
||||
}
|
||||
|
||||
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func compatImageProxyTagVariantMiddleware(codec *ResourceIDCodec) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !isCompatImageProxyClientRequest(r) || isWebSocketUpgrade(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rw := &compatImageProxyTagResponseWriter{ResponseWriter: w, codec: codec}
|
||||
next.ServeHTTP(rw, r)
|
||||
rw.finish()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type compatImageProxyTagResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
body bytes.Buffer
|
||||
passthrough bool
|
||||
codec *ResourceIDCodec
|
||||
}
|
||||
|
||||
func (w *compatImageProxyTagResponseWriter) WriteHeader(status int) {
|
||||
if w.passthrough {
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
w.status = status
|
||||
}
|
||||
|
||||
func (w *compatImageProxyTagResponseWriter) Write(p []byte) (int, error) {
|
||||
if w.passthrough {
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
if !isJSONResponse(w.Header().Get("Content-Type")) {
|
||||
w.passthrough = true
|
||||
w.ResponseWriter.WriteHeader(w.status)
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
return w.body.Write(p)
|
||||
}
|
||||
|
||||
func (w *compatImageProxyTagResponseWriter) finish() {
|
||||
if w.passthrough {
|
||||
return
|
||||
}
|
||||
if w.status == 0 && w.body.Len() == 0 {
|
||||
return
|
||||
}
|
||||
status := w.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
body := w.body.Bytes()
|
||||
if isJSONResponse(w.Header().Get("Content-Type")) && len(body) > 0 {
|
||||
body = rewriteCompatImageProxyTags(w.codec, body)
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
_, _ = w.ResponseWriter.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func isJSONResponse(contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
return contentType == "application/json" || strings.HasPrefix(contentType, "application/json;")
|
||||
}
|
||||
|
||||
func isWebSocketUpgrade(r *http.Request) bool {
|
||||
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
|
||||
}
|
||||
|
||||
func rewriteCompatImageProxyTags(codec *ResourceIDCodec, body []byte) []byte {
|
||||
var value any
|
||||
if err := json.Unmarshal(body, &value); err != nil {
|
||||
return body
|
||||
}
|
||||
if !appendCompatImageProxyTags(codec, value) {
|
||||
return body
|
||||
}
|
||||
rewritten, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return append(rewritten, '\n')
|
||||
}
|
||||
|
||||
func appendCompatImageProxyTags(codec *ResourceIDCodec, value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
changed := false
|
||||
hasPrimaryImageTag := compatMapHasPrimaryImageTag(typed)
|
||||
for key, child := range typed {
|
||||
switch key {
|
||||
case "ImageTags":
|
||||
changed = appendCompatImageProxyTagsInMap(child) || changed
|
||||
case "ImageBlurHashes":
|
||||
changed = appendCompatImageProxyTagsInBlurHashes(child) || changed
|
||||
case "BackdropImageTags", "ParentBackdropImageTags":
|
||||
changed = appendCompatImageProxyTagsInSlice(child) || changed
|
||||
case "PrimaryImageTag", "SeriesPrimaryImageTag", "ParentThumbImageTag", "BackdropImageTag", "ImageTag":
|
||||
if tag, ok := child.(string); ok && tag != "" {
|
||||
typed[key] = compatImageProxyTag(tag)
|
||||
changed = true
|
||||
}
|
||||
default:
|
||||
changed = appendCompatImageProxyTags(codec, child) || changed
|
||||
}
|
||||
}
|
||||
if hasPrimaryImageTag {
|
||||
changed = appendCompatPrimaryImageItemID(codec, typed) || changed
|
||||
}
|
||||
return changed
|
||||
case []any:
|
||||
changed := false
|
||||
for _, child := range typed {
|
||||
changed = appendCompatImageProxyTags(codec, child) || changed
|
||||
}
|
||||
return changed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compatMapHasPrimaryImageTag(value map[string]any) bool {
|
||||
if tags, ok := value["ImageTags"].(map[string]any); ok {
|
||||
if tag, ok := tags["Primary"].(string); ok && strings.TrimSpace(tag) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"PrimaryImageTag", "ImageTag"} {
|
||||
if tag, ok := value[key].(string); ok && strings.TrimSpace(tag) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendCompatPrimaryImageItemID(codec *ResourceIDCodec, value map[string]any) bool {
|
||||
if codec == nil {
|
||||
return false
|
||||
}
|
||||
routeID := compatStringMapValue(value, "PrimaryImageItemId")
|
||||
if routeID == "" {
|
||||
routeID = compatStringMapValue(value, "Id")
|
||||
}
|
||||
if routeID == "" {
|
||||
routeID = compatStringMapValue(value, "ItemId")
|
||||
}
|
||||
if routeID == "" {
|
||||
return false
|
||||
}
|
||||
proxyRouteID := compatImageProxyRouteID(codec, routeID)
|
||||
if proxyRouteID == "" || proxyRouteID == routeID {
|
||||
return false
|
||||
}
|
||||
if compatStringMapValue(value, "PrimaryImageItemId") == proxyRouteID {
|
||||
return false
|
||||
}
|
||||
value["PrimaryImageItemId"] = proxyRouteID
|
||||
return true
|
||||
}
|
||||
|
||||
func compatStringMapValue(value map[string]any, key string) string {
|
||||
if raw, ok := value[key].(string); ok {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendCompatImageProxyTagsInMap(value any) bool {
|
||||
tags, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for key, raw := range tags {
|
||||
if tag, ok := raw.(string); ok && tag != "" {
|
||||
tags[key] = compatImageProxyTag(tag)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func appendCompatImageProxyTagsInSlice(value any) bool {
|
||||
tags, ok := value.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for idx, raw := range tags {
|
||||
if tag, ok := raw.(string); ok && tag != "" {
|
||||
tags[idx] = compatImageProxyTag(tag)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func appendCompatImageProxyTagsInBlurHashes(value any) bool {
|
||||
byImageType, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for imageType, rawTags := range byImageType {
|
||||
tags, ok := rawTags.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next := make(map[string]any, len(tags))
|
||||
for tag, hash := range tags {
|
||||
next[compatImageProxyTag(tag)] = hash
|
||||
changed = true
|
||||
}
|
||||
byImageType[imageType] = next
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompatImageProxyTagVariantMiddlewareSuffixesInfuseImageTags(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: []baseItemDTO{{
|
||||
ID: "item-1",
|
||||
Name: "Movie",
|
||||
ImageTags: map[string]string{"Primary": "primary-tag"},
|
||||
BackdropImageTags: []string{"backdrop-tag"},
|
||||
SeriesPrimaryImageTag: "series-tag",
|
||||
ParentBackdropImageTags: []string{"parent-backdrop-tag"},
|
||||
ParentThumbImageTag: "parent-thumb-tag",
|
||||
ImageBlurHashes: map[string]map[string]string{
|
||||
"Primary": {"primary-tag": "thumbhash"},
|
||||
},
|
||||
}},
|
||||
TotalRecordCount: 1,
|
||||
})
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
|
||||
req.Header.Set("User-Agent", "Infuse-Direct/8.4.6")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
item := got.Items[0]
|
||||
if item.ImageTags["Primary"] != "primary-tag-p" {
|
||||
t.Fatalf("primary tag = %q, want primary-tag-p", item.ImageTags["Primary"])
|
||||
}
|
||||
if item.PrimaryImageItemID != compatImageProxyRouteID(codec, "item-1") {
|
||||
t.Fatalf("PrimaryImageItemId = %q, want proxy route id", item.PrimaryImageItemID)
|
||||
}
|
||||
if canonical, ok := canonicalCompatImageRouteID(codec, item.PrimaryImageItemID); !ok || canonical != "item-1" {
|
||||
t.Fatalf("canonical proxy route = %q, %v; want item-1, true", canonical, ok)
|
||||
}
|
||||
if item.BackdropImageTags[0] != "backdrop-tag-p" {
|
||||
t.Fatalf("backdrop tag = %q, want backdrop-tag-p", item.BackdropImageTags[0])
|
||||
}
|
||||
if item.SeriesPrimaryImageTag != "series-tag-p" {
|
||||
t.Fatalf("series tag = %q, want series-tag-p", item.SeriesPrimaryImageTag)
|
||||
}
|
||||
if item.ParentBackdropImageTags[0] != "parent-backdrop-tag-p" {
|
||||
t.Fatalf("parent backdrop tag = %q, want parent-backdrop-tag-p", item.ParentBackdropImageTags[0])
|
||||
}
|
||||
if item.ParentThumbImageTag != "parent-thumb-tag-p" {
|
||||
t.Fatalf("parent thumb tag = %q, want parent-thumb-tag-p", item.ParentThumbImageTag)
|
||||
}
|
||||
if _, ok := item.ImageBlurHashes["Primary"]["primary-tag-p"]; !ok {
|
||||
t.Fatalf("blurhash keys = %#v, want suffixed primary tag", item.ImageBlurHashes["Primary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatImageProxyTagVariantMiddlewareLeavesOtherClientsUnchanged(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: []baseItemDTO{{
|
||||
ID: "item-1",
|
||||
Name: "Movie",
|
||||
ImageTags: map[string]string{"Primary": "primary-tag"},
|
||||
}},
|
||||
TotalRecordCount: 1,
|
||||
})
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Items[0].ImageTags["Primary"] != "primary-tag" {
|
||||
t.Fatalf("primary tag = %q, want primary-tag", got.Items[0].ImageTags["Primary"])
|
||||
}
|
||||
if got.Items[0].PrimaryImageItemID != "" {
|
||||
t.Fatalf("PrimaryImageItemId = %q, want empty", got.Items[0].PrimaryImageItemID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatImageProxyRouteIDCanonicalizesNumericRouteWithoutRegistration(t *testing.T) {
|
||||
routeID := EncodeNumericID(EncodedIDItem, 12345).String()
|
||||
proxyRouteID := compatImageProxyRouteID(NewResourceIDCodec(), routeID)
|
||||
|
||||
canonical, ok := canonicalCompatImageRouteID(NewResourceIDCodec(), proxyRouteID)
|
||||
if !ok || canonical != routeID {
|
||||
t.Fatalf("canonical proxy route = %q, %v; want %q, true", canonical, ok, routeID)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
actual = canonicalCompatImageTag(actual)
|
||||
actual = strings.TrimSpace(actual)
|
||||
expected := s.Tag(seed, fallbackURL)
|
||||
if expected == "" || actual == "" || len(expected) != len(actual) {
|
||||
return false
|
||||
|
||||
+18
-196
@@ -2,226 +2,48 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var compatImageProxyUserAgentSubstrings = []string{
|
||||
"infuse",
|
||||
}
|
||||
|
||||
const compatImageRouteCacheControl = "private, no-store, no-cache, max-age=0, s-maxage=0, must-revalidate"
|
||||
const compatImageProxyTagSuffix = "-p"
|
||||
const compatImageProxyRoutePrefix = "__jellycompat_image_proxy__:"
|
||||
|
||||
type compatImageProxyRouteContextKey struct{}
|
||||
|
||||
var compatImageProxyHeaders = []string{
|
||||
"Accept-Ranges",
|
||||
"Content-Length",
|
||||
"Content-Type",
|
||||
"ETag",
|
||||
"Last-Modified",
|
||||
}
|
||||
|
||||
func shouldProxyCompatImageRequest(r *http.Request) bool {
|
||||
return isCompatImageProxyClientRequest(r) ||
|
||||
isCompatImageProxyRouteRequest(r) ||
|
||||
isCompatImageProxyTag(r.URL.Query().Get("tag"))
|
||||
}
|
||||
|
||||
func isCompatImageProxyClientRequest(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
clientText := strings.ToLower(strings.Join([]string{
|
||||
r.UserAgent(),
|
||||
r.Header.Get("X-Emby-Authorization"),
|
||||
}, " "))
|
||||
for _, substring := range compatImageProxyUserAgentSubstrings {
|
||||
if strings.Contains(clientText, substring) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compatImageProxyTag(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || isCompatImageProxyTag(tag) {
|
||||
return tag
|
||||
}
|
||||
return tag + compatImageProxyTagSuffix
|
||||
}
|
||||
|
||||
func isCompatImageProxyTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSpace(tag), compatImageProxyTagSuffix)
|
||||
}
|
||||
|
||||
func canonicalCompatImageTag(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
return strings.TrimSuffix(tag, compatImageProxyTagSuffix)
|
||||
}
|
||||
|
||||
func compatImageProxyRouteID(codec *ResourceIDCodec, routeID string) string {
|
||||
routeID = strings.TrimSpace(routeID)
|
||||
if codec == nil || routeID == "" {
|
||||
return routeID
|
||||
}
|
||||
if canonical, ok := canonicalCompatImageRouteID(codec, routeID); ok {
|
||||
return compatImageProxyRouteID(codec, canonical)
|
||||
}
|
||||
if decoded, ok := decodePackedCompatRouteID(routeID); ok {
|
||||
return encodeNumericCompatImageProxyRouteID(decoded)
|
||||
}
|
||||
return codec.EncodeStringID(EncodedIDItem, compatImageProxyRoutePrefix+routeID)
|
||||
}
|
||||
|
||||
func canonicalCompatImageRouteID(codec *ResourceIDCodec, routeID string) (string, bool) {
|
||||
if canonical, ok := decodeNumericCompatImageProxyRouteID(routeID); ok {
|
||||
return canonical, true
|
||||
}
|
||||
if codec == nil {
|
||||
return routeID, false
|
||||
}
|
||||
value, err := codec.DecodeStringID(EncodedIDItem, strings.TrimSpace(routeID))
|
||||
if err != nil || !strings.HasPrefix(value, compatImageProxyRoutePrefix) {
|
||||
return routeID, false
|
||||
}
|
||||
canonical := strings.TrimSpace(strings.TrimPrefix(value, compatImageProxyRoutePrefix))
|
||||
if canonical == "" {
|
||||
return routeID, false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func encodeNumericCompatImageProxyRouteID(decoded DecodedID) string {
|
||||
var raw [16]byte
|
||||
raw[0] = byte(EncodedIDImageProxy)
|
||||
raw[1] = byte(decoded.Type)
|
||||
binary.BigEndian.PutUint64(raw[8:], decoded.Value)
|
||||
return uuid.UUID(raw).String()
|
||||
}
|
||||
|
||||
func decodeNumericCompatImageProxyRouteID(routeID string) (string, bool) {
|
||||
parsed, err := uuid.Parse(strings.TrimSpace(routeID))
|
||||
if err != nil || EncodedIDType(parsed[0]) != EncodedIDImageProxy {
|
||||
return "", false
|
||||
}
|
||||
kind := EncodedIDType(parsed[1])
|
||||
if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[2:8]) {
|
||||
return "", false
|
||||
}
|
||||
return EncodeNumericID(kind, binary.BigEndian.Uint64(parsed[8:])).String(), true
|
||||
}
|
||||
|
||||
func decodePackedCompatRouteID(routeID string) (DecodedID, bool) {
|
||||
parsed, err := uuid.Parse(strings.TrimSpace(routeID))
|
||||
if err != nil {
|
||||
return DecodedID{}, false
|
||||
}
|
||||
kind := EncodedIDType(parsed[0])
|
||||
if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[1:8]) {
|
||||
return DecodedID{}, false
|
||||
}
|
||||
return DecodedID{Type: kind, Value: binary.BigEndian.Uint64(parsed[8:])}, true
|
||||
}
|
||||
|
||||
func isPackedCompatRouteIDType(kind EncodedIDType) bool {
|
||||
switch kind {
|
||||
case EncodedIDLibrary,
|
||||
EncodedIDItem,
|
||||
EncodedIDMediaSource,
|
||||
EncodedIDSeason,
|
||||
EncodedIDPlaySession,
|
||||
EncodedIDGenre,
|
||||
EncodedIDStudio,
|
||||
EncodedIDPerson:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func zeroUUIDBytes(values []byte) bool {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func withCompatImageProxyRouteRequest(r *http.Request) *http.Request {
|
||||
if r == nil {
|
||||
return r
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), compatImageProxyRouteContextKey{}, true))
|
||||
}
|
||||
|
||||
func isCompatImageProxyRouteRequest(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
forceProxy, _ := r.Context().Value(compatImageProxyRouteContextKey{}).(bool)
|
||||
return forceProxy
|
||||
}
|
||||
|
||||
func copyConditionalImageRequestHeaders(dst, src http.Header) {
|
||||
for _, key := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since"} {
|
||||
if value := src.Values(key); len(value) > 0 {
|
||||
dst[key] = append([]string(nil), value...)
|
||||
}
|
||||
}
|
||||
}
|
||||
const defaultImageProxyCacheControl = "public, max-age=300, must-revalidate"
|
||||
|
||||
func proxyImage(w http.ResponseWriter, resp *http.Response) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
copyImageProxyHeaders(w.Header(), resp.Header)
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
copyImageProxyHeaders(w.Header(), resp.Header)
|
||||
setCompatImageRouteNoStore(w.Header())
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
setCompatImageRouteNoStore(w.Header())
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
}
|
||||
|
||||
copyImageProxyHeaders(w.Header(), resp.Header)
|
||||
setCompatImageRouteNoStore(w.Header())
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func copyImageProxyHeaders(dst, src http.Header) {
|
||||
for _, key := range compatImageProxyHeaders {
|
||||
if value := src.Values(key); len(value) > 0 {
|
||||
dst[key] = append([]string(nil), value...)
|
||||
for _, name := range []string{"Content-Type", "Cache-Control", "ETag", "Last-Modified", "Expires"} {
|
||||
values := src.Values(name)
|
||||
if len(values) == 0 {
|
||||
values = src[name]
|
||||
}
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
dst.Del(name)
|
||||
for _, value := range values {
|
||||
dst.Add(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setCompatImageRouteNoStore(header http.Header) {
|
||||
header.Set("Cache-Control", compatImageRouteCacheControl)
|
||||
header.Set("CDN-Cache-Control", "private, no-store, no-cache, max-age=0")
|
||||
header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
|
||||
header.Set("Pragma", "no-cache")
|
||||
header.Set("Surrogate-Control", "no-store")
|
||||
header.Set("X-Accel-Expires", "0")
|
||||
header.Add("Vary", "User-Agent")
|
||||
if dst.Get("Cache-Control") == "" {
|
||||
dst.Set("Cache-Control", defaultImageProxyCacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderAvatarPNG(seed string) ([]byte, error) {
|
||||
|
||||
+119
-197
@@ -2,8 +2,10 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,66 +16,88 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
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,
|
||||
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")
|
||||
func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"ETag": []string{`"abc"`},
|
||||
"Last-Modified": []string{"Tue, 12 May 2026 12:00:00 GMT"},
|
||||
"Expires": []string{"Tue, 12 May 2026 12:05:00 GMT"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader("image-bytes")),
|
||||
}
|
||||
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
|
||||
t.Fatal("signed-tag image URL was not cached after resolution")
|
||||
|
||||
proxyImage(rec, resp)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != defaultImageProxyCacheControl {
|
||||
t.Fatalf("Cache-Control = %q, want %q", got, defaultImageProxyCacheControl)
|
||||
}
|
||||
if got := rec.Header().Get("ETag"); got != `"abc"` {
|
||||
t.Fatalf("ETag = %q", got)
|
||||
}
|
||||
if strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("Cache-Control unexpectedly immutable: %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
var gotIfNoneMatch string
|
||||
func TestProxyImageRelaysNotModified(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusNotModified,
|
||||
Header: http.Header{
|
||||
"ETag": []string{`"abc"`},
|
||||
"Cache-Control": []string{"public, max-age=60"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}
|
||||
|
||||
proxyImage(rec, resp)
|
||||
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Fatalf("status = %d, want 304", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("ETag"); got != `"abc"` {
|
||||
t.Fatalf("ETag = %q", got)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("304 body length = %d, want 0", rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("If-None-Match"); got != `"abc"` {
|
||||
t.Fatalf("If-None-Match = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("If-Modified-Since"); got != "Tue, 12 May 2026 12:00:00 GMT" {
|
||||
t.Fatalf("If-Modified-Since = %q", got)
|
||||
}
|
||||
w.Header().Set("ETag", `"abc"`)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
h := &ImagesHandler{httpClient: upstream.Client()}
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil)
|
||||
req.Header.Set("If-None-Match", `"abc"`)
|
||||
req.Header.Set("If-Modified-Since", "Tue, 12 May 2026 12:00:00 GMT")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.proxyImageURL(rec, req, upstream.URL)
|
||||
|
||||
if rec.Code != http.StatusNotModified {
|
||||
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) {
|
||||
upstreamCalled = true
|
||||
gotIfNoneMatch = r.Header.Get("If-None-Match")
|
||||
w.Header().Set("Cache-Control", "public, max-age=14400")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("ETag", `"poster-v1"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("image-bytes"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
@@ -100,15 +124,13 @@ func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T
|
||||
}, 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),
|
||||
httpClient: upstream.Client(),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil)
|
||||
req.Header.Set("If-None-Match", `"poster-v1"`)
|
||||
req.Header.Set("User-Agent", "Infuse-Direct/8.4.6")
|
||||
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()
|
||||
|
||||
@@ -118,90 +140,10 @@ func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T
|
||||
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 got := rec.Header().Get("Location"); got != "" {
|
||||
t.Fatalf("Location = %q, want empty", got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Fatalf("Content-Type = %q, want image/jpeg", got)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
|
||||
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
|
||||
}
|
||||
if got := rec.Header().Get("CDN-Cache-Control"); got != "private, no-store, no-cache, max-age=0" {
|
||||
t.Fatalf("CDN-Cache-Control = %q, want private, no-store, no-cache, max-age=0", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-Accel-Expires"); got != "0" {
|
||||
t.Fatalf("X-Accel-Expires = %q, want 0", got)
|
||||
}
|
||||
if got := gotIfNoneMatch; got != `"poster-v1"` {
|
||||
t.Fatalf("forwarded If-None-Match = %q, want poster-v1", got)
|
||||
}
|
||||
if !upstreamCalled {
|
||||
t.Fatal("Infuse compat image route did not proxy the upstream image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageProxyRouteIDUsesCanonicalItemAndProxy(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("proxy-route-image"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
codec := NewResourceIDCodec()
|
||||
contentID := "movie-1"
|
||||
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
proxyRouteID := compatImageProxyRouteID(codec, routeID)
|
||||
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,
|
||||
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
|
||||
httpClient: upstream.Client(),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+proxyRouteID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil)
|
||||
req = withImageRouteParams(req, proxyRouteID, "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 != "proxy-route-image" {
|
||||
t.Fatalf("body = %q, want proxy-route-image", got)
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "" {
|
||||
t.Fatalf("Location = %q, want empty", got)
|
||||
t.Fatalf("body = %q, want image bytes", got)
|
||||
}
|
||||
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
|
||||
t.Fatal("proxy route image URL was not cached under the canonical route ID")
|
||||
}
|
||||
if !upstreamCalled {
|
||||
t.Fatal("proxy route did not fetch the upstream image")
|
||||
t.Fatal("signed-tag image URL was not cached after resolution")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +168,10 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(""),
|
||||
codec: codec,
|
||||
httpClient: http.DefaultClient,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(""),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
|
||||
@@ -243,10 +186,9 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("backdrop-bytes"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -259,8 +201,9 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t
|
||||
upstream.URL,
|
||||
)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
BackdropPath: upstream.URL,
|
||||
@@ -274,17 +217,18 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
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) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("library-poster"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -299,6 +243,7 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.
|
||||
)
|
||||
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},
|
||||
@@ -311,17 +256,18 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
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) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-image"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -330,9 +276,10 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing
|
||||
cache := NewImageCache(time.Hour, time.Now)
|
||||
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
images: cache,
|
||||
imageTags: newImageTagSigner("image-secret"),
|
||||
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)
|
||||
@@ -341,9 +288,11 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,10 +328,11 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
images: cache,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner("new-secret"),
|
||||
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)
|
||||
@@ -399,34 +349,6 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectImageURLRejectsNonHTTPURL(t *testing.T) {
|
||||
h := &ImagesHandler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.redirectImageURL(rec, req, "catalog/poster.jpg")
|
||||
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, body = %s; want 502", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "" {
|
||||
t.Fatalf("Location = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertImageRedirect(t *testing.T, rec *httptest.ResponseRecorder, wantLocation string) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, body = %s; want 302", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != wantLocation {
|
||||
t.Fatalf("Location = %q, want %q", got, wantLocation)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
|
||||
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeImageItemRepo struct {
|
||||
item *models.MediaItem
|
||||
}
|
||||
|
||||
@@ -106,10 +106,8 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
|
||||
dto.RecursiveItemCount = *item.EpisodeCount
|
||||
}
|
||||
if item.SeasonCount != nil {
|
||||
seasonCount := *item.SeasonCount
|
||||
dto.ChildCount = seasonCount
|
||||
dto.RecursiveItemCount = seasonCount
|
||||
dto.SeasonCount = seasonCount
|
||||
dto.ChildCount = *item.SeasonCount
|
||||
dto.RecursiveItemCount = *item.SeasonCount
|
||||
}
|
||||
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
|
||||
if tags := imageTagsWithSeed(m.imageTagSigner,
|
||||
|
||||
@@ -35,39 +35,3 @@ func TestItemListSortNamePrefersSortTitle(t *testing.T) {
|
||||
t.Fatalf("SortName = %q, want %q", dto.SortName, "Matrix, The")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesListIncludesSeasonCount(t *testing.T) {
|
||||
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
||||
seasonCount := 4
|
||||
dto := m.itemFromList(upstreamListItem{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Snowpiercer",
|
||||
SeasonCount: &seasonCount,
|
||||
}, false, nil, nil)
|
||||
|
||||
if dto.SeasonCount != 4 {
|
||||
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
|
||||
}
|
||||
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
|
||||
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesDetailIncludesSeasonCount(t *testing.T) {
|
||||
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
||||
seasonCount := 4
|
||||
dto := m.itemFromDetail(upstreamItemDetail{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Snowpiercer",
|
||||
SeasonCount: &seasonCount,
|
||||
}, false, nil)
|
||||
|
||||
if dto.SeasonCount != 4 {
|
||||
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
|
||||
}
|
||||
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
|
||||
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba
|
||||
return &cp, nil, true
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if mediaSourceIDsEqual(source.ID, routeID) {
|
||||
if source.ID == routeID {
|
||||
cp := session
|
||||
sourceCopy := source
|
||||
return &cp, &sourceCopy, true
|
||||
|
||||
@@ -19,7 +19,6 @@ type itemsQuery struct {
|
||||
namePrefix string
|
||||
maxOfficialRating string
|
||||
parentLibraryID int
|
||||
parentItemID string
|
||||
specificIDs []string
|
||||
itemTypes []string
|
||||
genreName string
|
||||
@@ -33,7 +32,6 @@ type itemsQuery struct {
|
||||
personID int64
|
||||
isPlayed *bool // nil = not specified
|
||||
imageTypeLimit *int // nil = not specified
|
||||
requireBackdrop bool // true when ImageTypes includes Backdrop (filter, not just a hint)
|
||||
mediaTypes []string
|
||||
mediaTypesSet map[string]bool
|
||||
mediaTypesExplicit bool
|
||||
@@ -57,8 +55,6 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
|
||||
if parentID := strings.TrimSpace(q.Get("ParentId")); parentID != "" {
|
||||
if libraryID, err := codec.DecodeIntID(EncodedIDLibrary, parentID); err == nil {
|
||||
result.parentLibraryID = int(libraryID)
|
||||
} else if contentID, itemErr := decodeItemID(codec, parentID); itemErr == nil && contentID != "" {
|
||||
result.parentItemID = contentID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,17 +111,6 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
|
||||
}
|
||||
}
|
||||
|
||||
// ImageTypes acts as a filter: clients (e.g. Wholphin genre cards) request
|
||||
// ImageTypes=Backdrop and assume every returned item has a backdrop. Only
|
||||
// Backdrop is enforced — the catalog browse path can filter on backdrop_path.
|
||||
for _, raw := range q.Values("ImageTypes") {
|
||||
for part := range strings.SplitSeq(raw, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), "Backdrop") {
|
||||
result.requireBackdrop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaTypesRaw := q.Values("MediaTypes")
|
||||
result.mediaTypes = parseMediaTypes(mediaTypesRaw)
|
||||
result.mediaTypesExplicit = len(mediaTypesRaw) > 0 && strings.TrimSpace(strings.Join(mediaTypesRaw, "")) != ""
|
||||
@@ -226,9 +211,6 @@ func buildBrowseParams(query itemsQuery) url.Values {
|
||||
params.Set("is_played", "false")
|
||||
}
|
||||
}
|
||||
if query.requireBackdrop {
|
||||
params.Set("require_backdrop", "true")
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseItemsQuery_ImageTypesBackdropFilter(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
wantRequire bool
|
||||
}{
|
||||
{"backdrop requested", "ImageTypes=Backdrop", true},
|
||||
{"lowercase param and value", "imagetypes=backdrop", true},
|
||||
{"backdrop among several", "ImageTypes=Primary,Backdrop,Logo", true},
|
||||
{"bracket array variant", "ImageTypes[]=Backdrop", true},
|
||||
{"primary only does not filter", "ImageTypes=Primary", false},
|
||||
{"absent does not filter", "Limit=1", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items?"+tc.rawQuery, nil)
|
||||
query := parseItemsQuery(req, codec)
|
||||
if query.requireBackdrop != tc.wantRequire {
|
||||
t.Fatalf("requireBackdrop = %v, want %v", query.requireBackdrop, tc.wantRequire)
|
||||
}
|
||||
params := buildBrowseParams(query)
|
||||
gotParam := params.Get("require_backdrop") == "true"
|
||||
if gotParam != tc.wantRequire {
|
||||
t.Fatalf("require_backdrop param = %q, want emitted=%v", params.Get("require_backdrop"), tc.wantRequire)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
slog.Info("jellycompat debug logging enabled", logAttrs...)
|
||||
}
|
||||
r.Use(compatImageProxyTagVariantMiddleware(deps.IDCodec))
|
||||
r.Use(requestLoggerMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
@@ -100,7 +99,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.S3Client = deps.S3Client
|
||||
playbackHandler.S3Bucket = deps.S3Bucket
|
||||
}
|
||||
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret, deps.HTTPClient)
|
||||
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)
|
||||
|
||||
@@ -262,15 +261,15 @@ func withDefaults(deps Dependencies) Dependencies {
|
||||
if deps.Config != nil && deps.Config.JellyfinCompat.PlaybackSessionTTL > 0 {
|
||||
playbackTTL = deps.Config.JellyfinCompat.PlaybackSessionTTL
|
||||
}
|
||||
if deps.HTTPClient == nil {
|
||||
deps.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
if deps.DeviceProfiles == nil {
|
||||
deps.DeviceProfiles = NewDeviceProfileStore(playbackTTL, deps.Now)
|
||||
}
|
||||
if deps.PlaybackStore == nil {
|
||||
deps.PlaybackStore = NewPlaybackSessionStore(playbackTTL, deps.Now)
|
||||
}
|
||||
if deps.HTTPClient == nil {
|
||||
deps.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// Build ContentService from repos if not provided
|
||||
if deps.ContentService == nil && deps.BrowseRepo != nil && deps.ItemRepo != nil && deps.DetailSvc != nil {
|
||||
|
||||
@@ -27,6 +27,7 @@ type Dependencies struct {
|
||||
DB *pgxpool.Pool
|
||||
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
|
||||
ClientIPResolver *clientip.Resolver
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
TokenGenerator func() string
|
||||
SessionStore *SessionStore
|
||||
@@ -37,7 +38,6 @@ type Dependencies struct {
|
||||
LoginResolver loginResolver
|
||||
Authenticator *Authenticator
|
||||
WebFS fs.FS
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Direct service dependencies (replaces Client)
|
||||
ContentService ContentService
|
||||
@@ -145,5 +145,6 @@ func NewDependencies(cfg *config.Config) Dependencies {
|
||||
Config: cfg,
|
||||
Now: time.Now,
|
||||
TokenGenerator: uuid.NewString,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId")
|
||||
playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID"))
|
||||
if playSessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "PlaySessionId is required")
|
||||
return
|
||||
@@ -906,7 +906,7 @@ func audioSelectionChanged(session *PlaybackSession, mediaSourceID string, incom
|
||||
return true
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if mediaSourceID != "" && !mediaSourceIDsEqual(source.ID, mediaSourceID) {
|
||||
if mediaSourceID != "" && source.ID != mediaSourceID {
|
||||
continue
|
||||
}
|
||||
if source.SelectedAudioStreamIndex == nil {
|
||||
@@ -925,7 +925,7 @@ func (h *PlaybackHandler) setSelectedAudioStream(playSessionID, mediaSourceID st
|
||||
if mediaSourceID != "" {
|
||||
sourceIndex = -1
|
||||
for index := range current.MediaSources {
|
||||
if mediaSourceIDsEqual(current.MediaSources[index].ID, mediaSourceID) {
|
||||
if current.MediaSources[index].ID == mediaSourceID {
|
||||
sourceIndex = index
|
||||
break
|
||||
}
|
||||
@@ -1061,7 +1061,7 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) {
|
||||
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
|
||||
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
|
||||
playSession, ok := h.playbackStore.Get(playSessionID)
|
||||
if !ok || playSession.CompatToken != compatSession.Token {
|
||||
return nil, nil, ErrSessionNotFound
|
||||
@@ -1099,7 +1099,7 @@ func findMediaSource(session *PlaybackSession, mediaSourceID string) *PlaybackMe
|
||||
return nil
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if mediaSourceIDsEqual(source.ID, mediaSourceID) {
|
||||
if source.ID == mediaSourceID {
|
||||
copy := source
|
||||
return ©
|
||||
}
|
||||
|
||||
+21
-66
@@ -303,21 +303,9 @@ func resolveEnabledProvidersByPriority(
|
||||
priority int
|
||||
}
|
||||
|
||||
// Only providers that support contentLevel participate in the chain-less
|
||||
// fallback. A provider declaring a non-empty default_priority map that omits
|
||||
// this level is excluded outright (not merely ranked last), so a
|
||||
// single-purpose provider is never invoked for content it does not handle.
|
||||
items := make([]ranked, 0, len(caps))
|
||||
for _, c := range caps {
|
||||
metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID)
|
||||
if !providerSupportsLevel(metadataJSON, contentLevel) {
|
||||
slog.Debug("skipping metadata provider: does not declare support for content level",
|
||||
"installation_id", c.PluginInstallationID,
|
||||
"capability_id", c.CapabilityID,
|
||||
"content_level", contentLevel)
|
||||
continue
|
||||
}
|
||||
items = append(items, ranked{cap: c, priority: extractDefaultPriority(metadataJSON, contentLevel)})
|
||||
items := make([]ranked, len(caps))
|
||||
for i, c := range caps {
|
||||
items[i] = ranked{cap: c, priority: LookupDefaultPriority(ctx, pool, c.PluginInstallationID, contentLevel)}
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
@@ -338,68 +326,32 @@ func resolveEnabledProvidersByPriority(
|
||||
return buildProviders(ctx, sorted, resolver, pool), nil
|
||||
}
|
||||
|
||||
// providerSupportsLevel reports whether a metadata provider should participate
|
||||
// in the chain-less global fallback for contentLevel. A provider that declares
|
||||
// a non-empty default_priority map is treated as enumerating the content levels
|
||||
// it supports: it is eligible only for levels present in that map with a
|
||||
// positive priority. A provider that declares no default_priority makes no
|
||||
// claim and stays eligible for every level (legacy behavior), ranked last.
|
||||
//
|
||||
// This is what keeps a single-purpose provider (e.g. an audiobook metadata
|
||||
// provider declaring only {"audiobook": N}) from being pulled into video
|
||||
// content levels when a library has no enabled chain entry for that level.
|
||||
func providerSupportsLevel(metadataJSON []byte, contentLevel string) bool {
|
||||
levels, declared := declaredPriorityLevels(metadataJSON)
|
||||
if !declared {
|
||||
return true
|
||||
}
|
||||
return levels[contentLevel] > 0
|
||||
}
|
||||
|
||||
// lookupCapabilityMetadata returns the raw plugin_capabilities.metadata JSON for
|
||||
// a provider's exact metadata_provider.v1 capability, or nil if absent.
|
||||
func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID string) []byte {
|
||||
// LookupDefaultPriority queries plugin_capabilities for a provider's declared
|
||||
// default_priority at the given content level. Returns 0 if not found.
|
||||
func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, contentLevel string) int {
|
||||
var metadataJSON []byte
|
||||
err := pool.QueryRow(ctx,
|
||||
`SELECT metadata FROM plugin_capabilities
|
||||
WHERE plugin_installation_id = $1
|
||||
AND capability_id = $2
|
||||
AND capability_type = 'metadata_provider.v1'`,
|
||||
WHERE plugin_installation_id = $1 AND capability_type = 'metadata_provider.v1'
|
||||
LIMIT 1`,
|
||||
pluginInstallationID,
|
||||
capabilityID,
|
||||
).Scan(&metadataJSON)
|
||||
if err != nil {
|
||||
return nil
|
||||
return 0
|
||||
}
|
||||
return metadataJSON
|
||||
}
|
||||
|
||||
// LookupDefaultPriority queries plugin_capabilities for a provider's declared
|
||||
// default_priority at the given content level. Returns 0 if not found.
|
||||
func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) int {
|
||||
return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID), contentLevel)
|
||||
return extractDefaultPriority(metadataJSON, contentLevel)
|
||||
}
|
||||
|
||||
// extractDefaultPriority parses the default_priority for a content level from
|
||||
// capability metadata JSON.
|
||||
func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
|
||||
levels, _ := declaredPriorityLevels(metadataJSON)
|
||||
if v, ok := levels[contentLevel]; ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// declaredPriorityLevels parses a capability's default_priority map. The map may
|
||||
// sit at the top level or inside a "metadata" envelope (plugin capability
|
||||
// metadata wraps plugin-declared fields in a "metadata" sub-object). The second
|
||||
// return value is true only when a non-empty map was found, i.e. the provider
|
||||
// explicitly enumerates the content levels it supports.
|
||||
func declaredPriorityLevels(metadataJSON []byte) (map[string]float64, bool) {
|
||||
var meta map[string]json.RawMessage
|
||||
if err := json.Unmarshal(metadataJSON, &meta); err != nil {
|
||||
return nil, false
|
||||
return 0
|
||||
}
|
||||
// default_priority may be at the top level or nested inside a "metadata" sub-object
|
||||
// (plugin capability metadata wraps plugin-declared fields in a "metadata" envelope).
|
||||
dpRaw, ok := meta["default_priority"]
|
||||
if !ok {
|
||||
if innerRaw, innerOK := meta["metadata"]; innerOK {
|
||||
@@ -408,15 +360,18 @@ func declaredPriorityLevels(metadataJSON []byte) (map[string]float64, bool) {
|
||||
dpRaw, ok = inner["default_priority"]
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, false
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
var dpMap map[string]float64
|
||||
if err := json.Unmarshal(dpRaw, &dpMap); err != nil {
|
||||
return nil, false
|
||||
return 0
|
||||
}
|
||||
return dpMap, len(dpMap) > 0
|
||||
if v, ok := dpMap[contentLevel]; ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package metadata
|
||||
|
||||
import "testing"
|
||||
|
||||
// Capability metadata as stored in plugin_capabilities.metadata: plugin-declared
|
||||
// fields are wrapped in a "metadata" envelope.
|
||||
const (
|
||||
audiobookCapMetadata = `{"metadata":{"default_priority":{"audiobook":2}},"display_name":"Audiobook Metadata"}`
|
||||
tmdbCapMetadata = `{"metadata":{"default_priority":{"movie":2,"season":3,"series":3,"episode":3}},"display_name":"TMDB"}`
|
||||
tvdbCapMetadata = `{"metadata":{"default_priority":{"season":2,"series":2,"episode":2}},"display_name":"TVDB"}`
|
||||
)
|
||||
|
||||
func TestProviderSupportsLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metadataJSON string
|
||||
contentLevel string
|
||||
want bool
|
||||
}{
|
||||
// An audiobook-only provider must NOT be pulled into video content
|
||||
// levels by the chain-less global fallback. This is the regression
|
||||
// that let silo.audiobook-metadata hammer audiobook APIs with
|
||||
// anime/movie/series titles.
|
||||
{"audiobook provider excluded from episode", audiobookCapMetadata, "episode", false},
|
||||
{"audiobook provider excluded from series", audiobookCapMetadata, "series", false},
|
||||
{"audiobook provider excluded from season", audiobookCapMetadata, "season", false},
|
||||
{"audiobook provider excluded from movie", audiobookCapMetadata, "movie", false},
|
||||
{"audiobook provider included for audiobook", audiobookCapMetadata, "audiobook", true},
|
||||
|
||||
// Providers that declare a level stay eligible for it.
|
||||
{"tmdb included for movie", tmdbCapMetadata, "movie", true},
|
||||
{"tmdb included for season", tmdbCapMetadata, "season", true},
|
||||
{"tmdb included for episode", tmdbCapMetadata, "episode", true},
|
||||
|
||||
// tvdb declares no movie level -> excluded from the movie fallback.
|
||||
{"tvdb excluded from movie", tvdbCapMetadata, "movie", false},
|
||||
{"tvdb included for series", tvdbCapMetadata, "series", true},
|
||||
|
||||
// A provider that declares no default_priority makes no claim and stays
|
||||
// eligible everywhere (legacy behavior), ranked last by priority 0.
|
||||
{"no metadata is eligible", ``, "episode", true},
|
||||
{"empty object is eligible", `{}`, "episode", true},
|
||||
{"empty priority map is eligible", `{"metadata":{"default_priority":{}}}`, "episode", true},
|
||||
{"malformed json is eligible (fail-open)", `{not json`, "episode", true},
|
||||
|
||||
// A declared level with non-positive priority is not supported.
|
||||
{"zero priority level not supported", `{"metadata":{"default_priority":{"episode":0}}}`, "episode", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := providerSupportsLevel([]byte(tc.metadataJSON), tc.contentLevel)
|
||||
if got != tc.want {
|
||||
t.Fatalf("providerSupportsLevel(%q, %q) = %v, want %v",
|
||||
tc.metadataJSON, tc.contentLevel, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,32 +18,8 @@ const (
|
||||
movieQueueRetryDelay = 15 * time.Second
|
||||
seriesRootQueueQuietWindow = 10 * time.Second
|
||||
seriesRootQueueRetryDelay = 30 * time.Second
|
||||
|
||||
// matchQueueRetryMaxDelay caps the exponential retry backoff shared by both
|
||||
// match queues. Terminal outcomes like "no metadata found from any
|
||||
// provider" stay in the queue but decay to at most one attempt per day
|
||||
// instead of hot-looping at the base delay forever. A row self-heals by
|
||||
// being deleted on the first successful attempt.
|
||||
matchQueueRetryMaxDelay = 24 * time.Hour
|
||||
// matchQueueBackoffMaxExponent clamps the 2^attempt_count factor so the
|
||||
// float math stays finite for rows that accumulated very large attempt
|
||||
// counts before backoff existed.
|
||||
matchQueueBackoffMaxExponent = 16
|
||||
)
|
||||
|
||||
// matchQueueBackoffExpr returns the SQL expression both match queues use to
|
||||
// schedule the next retry after a failure: base_delay * 2^attempt_count,
|
||||
// capped at matchQueueRetryMaxDelay. attempt_count is incremented when a row
|
||||
// is claimed, so the first failure backs off to twice the base delay.
|
||||
// basePlaceholder and maxPlaceholder name the interval bind parameters (e.g.
|
||||
// "$3", "$4") in the caller's statement.
|
||||
func matchQueueBackoffExpr(basePlaceholder, maxPlaceholder string) string {
|
||||
return fmt.Sprintf(
|
||||
"NOW() + LEAST(%s::interval * power(2::float8, LEAST(attempt_count, %d)), %s::interval)",
|
||||
basePlaceholder, matchQueueBackoffMaxExponent, maxPlaceholder,
|
||||
)
|
||||
}
|
||||
|
||||
type MovieMatchQueueRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
fileRepo *scannerrepo.FileRepository
|
||||
@@ -501,10 +477,10 @@ func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
UPDATE movie_match_queue
|
||||
SET last_error = $2,
|
||||
available_at = `+matchQueueBackoffExpr("$3", "$4")+`,
|
||||
available_at = NOW() + $3::interval,
|
||||
updated_at = NOW()
|
||||
WHERE media_file_id = $1
|
||||
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay)); err != nil {
|
||||
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay)); err != nil {
|
||||
return fmt.Errorf("updating movie queue error: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -550,10 +550,10 @@ func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folder
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE series_root_match_queue
|
||||
SET last_error = $3,
|
||||
available_at = `+matchQueueBackoffExpr("$4", "$5")+`,
|
||||
available_at = NOW() + $4::interval,
|
||||
updated_at = NOW()
|
||||
WHERE media_folder_id = $1 AND observed_root_path = $2
|
||||
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay))
|
||||
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay))
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating series root queue error: %w", err)
|
||||
}
|
||||
|
||||
@@ -3856,6 +3856,10 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
if res.Type == "" {
|
||||
res.Type = "movie"
|
||||
}
|
||||
folderType := res.Type
|
||||
if folderType == "" {
|
||||
folderType = "movie"
|
||||
}
|
||||
|
||||
// Explicit structured IDs from the file or folder are treated as trusted.
|
||||
// When present, they override scanner ambiguity and become the authoritative
|
||||
@@ -3870,9 +3874,9 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
// Parse observed location names for external IDs before falling back to the
|
||||
// legacy canonical root path. This preserves existing heuristic behavior for
|
||||
// roots without explicit structured tags.
|
||||
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath))
|
||||
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath), folderType)
|
||||
if folderIDs == nil && contentRootPath != "" && contentRootPath != observedRootPath {
|
||||
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath))
|
||||
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath), folderType)
|
||||
}
|
||||
|
||||
effectiveExternalIDs := folderIDs
|
||||
|
||||
+50
-24
@@ -3,6 +3,7 @@ package naming
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205},
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
// The regex captures the provider prefix and the ID value.
|
||||
var folderIDPattern = regexp.MustCompile(`[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-([\w]+)[}\]]`)
|
||||
var trailingImdbIDPattern = regexp.MustCompile(`(?i)(?:^|\s)(tt\d{7,8})$`)
|
||||
var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`)
|
||||
|
||||
// bracketedBareImdbPattern matches a bare IMDb id wrapped in brackets without a
|
||||
// provider prefix, e.g. [tt10011226] or {tt0095016} (Plex/Kodi-style tags). A
|
||||
@@ -46,30 +48,54 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints {
|
||||
return hints
|
||||
}
|
||||
|
||||
// ParseFolderIDs extracts external provider IDs from a folder name. Mirroring
|
||||
// Jellyfin's path-attribute model, only explicit evidence is honored: bracket
|
||||
// tags with provider prefixes ({tmdb-27205}, [tvdbid-81189], [imdbid-tt1375666]),
|
||||
// bracketed bare IMDb ids ([tt1375666]), and a trailing bare IMDb id — the
|
||||
// "tt" prefix makes IMDb ids unambiguous without brackets. Bare trailing
|
||||
// numbers are never treated as IDs: titles legitimately end in numbers
|
||||
// ("District 9", "Beverly Hills 90210"), no mainstream tool emits bare-number
|
||||
// tags, and a misparsed ID becomes a trusted match hint downstream where it
|
||||
// silently produces a wrong match or blocks matching entirely.
|
||||
func ParseFolderIDs(folderName string) *FolderIDHints {
|
||||
hints := ParseStructuredFolderIDs(folderName)
|
||||
|
||||
// A trailing bare IMDb id complements structured tags for other providers
|
||||
// ("Show [tvdbid-81189] tt1375666"); an explicit structured imdb tag still
|
||||
// wins when both are present.
|
||||
trimmed := strings.TrimSpace(folderName)
|
||||
if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil {
|
||||
if hints == nil {
|
||||
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
|
||||
}
|
||||
if hints.ImdbID == "" {
|
||||
hints.ImdbID = strings.ToLower(m[1])
|
||||
}
|
||||
// ParseFolderIDs extracts external provider IDs from a folder name.
|
||||
// It supports bracket styles [] and {} with provider prefixes tmdb/tmdbid,
|
||||
// imdb/imdbid, tvdb/tvdbid, plus bare trailing IDs. Bare numeric IDs are
|
||||
// interpreted using folderType: series -> TVDB, everything else -> TMDB.
|
||||
func ParseFolderIDs(folderName string, folderType string) *FolderIDHints {
|
||||
if hints := ParseStructuredFolderIDs(folderName); hints != nil {
|
||||
return hints
|
||||
}
|
||||
|
||||
return hints
|
||||
trimmed := strings.TrimSpace(folderName)
|
||||
if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil {
|
||||
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
|
||||
}
|
||||
|
||||
m := trailingNumericIDPattern.FindStringSubmatch(trimmed)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := m[1]
|
||||
if looksLikeYear(id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A bare trailing number is only an ID when appended to a real title. If the
|
||||
// name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID.
|
||||
// Trade-off: a folder named ONLY a bare provider id (e.g. "81189") with no
|
||||
// title text now returns nil rather than that id — acceptable because
|
||||
// Sonarr/Radarr never produce bare-number folders.
|
||||
if !containsLetter(trimmed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(folderType), "series") {
|
||||
return &FolderIDHints{TvdbID: id}
|
||||
}
|
||||
return &FolderIDHints{TmdbID: id}
|
||||
}
|
||||
|
||||
func containsLetter(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeYear(value string) bool {
|
||||
return len(value) == 4 && value >= "1800" && value <= "2100"
|
||||
}
|
||||
|
||||
@@ -2,63 +2,36 @@ package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
// Bare numbers in folder names are never provider IDs. This mirrors
|
||||
// Jellyfin's path-attribute model: only explicit bracket tags (and
|
||||
// unambiguous tt-prefixed IMDb ids) carry identity. Titles legitimately end
|
||||
// in numbers, and a misparsed bare ID becomes a trusted match hint that
|
||||
// silently mismatches or blocks matching.
|
||||
func TestParseFolderIDs_BareNumbersAreNeverIDs(t *testing.T) {
|
||||
for _, c := range []string{
|
||||
// Numeric-only titles (anime and otherwise).
|
||||
"86",
|
||||
"22 7",
|
||||
// Titles ending in a short number.
|
||||
"District 9",
|
||||
"Apollo 13",
|
||||
"Ocean's 11",
|
||||
"THX 1138",
|
||||
"Stranger Things 4",
|
||||
// Season-style directory names.
|
||||
"Season 01",
|
||||
// Titles ending in a long number that looks like a plausible ID.
|
||||
"Beverly Hills 90210",
|
||||
// Bare trailing numbers that previously parsed as IDs.
|
||||
"Some Show 81189",
|
||||
"進撃の巨人 73743",
|
||||
} {
|
||||
if got := ParseFolderIDs(c); got != nil {
|
||||
t.Errorf(`ParseFolderIDs(%q) = %+v, want nil`, c, got)
|
||||
}
|
||||
func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) {
|
||||
// Numeric-only anime titles must NOT be parsed as a bare trailing tvdb/tmdb id.
|
||||
if got := ParseFolderIDs("86", "series"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("86","series") = %+v, want nil`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFolderIDs_StructuredTagsStillParse(t *testing.T) {
|
||||
got := ParseFolderIDs("{tmdb-27205}")
|
||||
if got == nil || got.TmdbID != "27205" {
|
||||
t.Errorf(`ParseFolderIDs("{tmdb-27205}") = %+v, want TmdbID="27205"`, got)
|
||||
if got := ParseFolderIDs("22 7", "series"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got)
|
||||
}
|
||||
|
||||
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189]")
|
||||
// A real bare trailing id with title text must still be parsed.
|
||||
got := ParseFolderIDs("Some Show 81189", "series")
|
||||
if got == nil || got.TvdbID != "81189" {
|
||||
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189]") = %+v, want TvdbID="81189"`, got)
|
||||
t.Errorf(`ParseFolderIDs("Some Show 81189","series") = %+v, want TvdbID="81189"`, got)
|
||||
}
|
||||
|
||||
// Trailing bare IMDb ids stay supported — the tt prefix is unambiguous.
|
||||
got = ParseFolderIDs("Some Movie (2010) tt1375666")
|
||||
if got == nil || got.ImdbID != "tt1375666" {
|
||||
t.Errorf(`ParseFolderIDs("Some Movie (2010) tt1375666") = %+v, want ImdbID="tt1375666"`, got)
|
||||
// Structured tags must still win regardless of letters.
|
||||
got = ParseFolderIDs("{tmdb-27205}", "movies")
|
||||
if got == nil || got.TmdbID != "27205" {
|
||||
t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got)
|
||||
}
|
||||
|
||||
// A structured tag for one provider must not swallow a trailing bare IMDb
|
||||
// id for another — both merge.
|
||||
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189] tt1375666")
|
||||
if got == nil || got.TvdbID != "81189" || got.ImdbID != "tt1375666" {
|
||||
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189] tt1375666") = %+v, want TvdbID="81189" and ImdbID="tt1375666"`, got)
|
||||
// Non-Latin (CJK) title with a trailing real id must still parse —
|
||||
// unicode.IsLetter covers kana/kanji, so this is treated as title + id.
|
||||
got = ParseFolderIDs("進撃の巨人 73743", "series")
|
||||
if got == nil || got.TvdbID != "73743" {
|
||||
t.Errorf(`ParseFolderIDs("進撃の巨人 73743","series") = %+v, want TvdbID="73743"`, got)
|
||||
}
|
||||
|
||||
// An explicit structured imdb tag still wins over a trailing bare id.
|
||||
got = ParseFolderIDs("Some Movie [imdb-tt1111111] tt2222222")
|
||||
if got == nil || got.ImdbID != "tt1111111" {
|
||||
t.Errorf(`ParseFolderIDs("Some Movie [imdb-tt1111111] tt2222222") = %+v, want ImdbID="tt1111111"`, got)
|
||||
// Numeric-only title for a movies library is also not an id.
|
||||
if got := ParseFolderIDs("86", "movies"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("86","movies") = %+v, want nil`, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,19 @@ func TestParseFolderIDs_BracketedBareImdb(t *testing.T) {
|
||||
{"8-digit tt", "Movie (2024) [tt12345678]", "tt12345678"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ParseFolderIDs(c.folder)
|
||||
got := ParseFolderIDs(c.folder, "movie")
|
||||
if got == nil || got.ImdbID != c.wantImdb {
|
||||
t.Errorf("%s: ParseFolderIDs(%q) = %+v, want ImdbID=%q", c.name, c.folder, got, c.wantImdb)
|
||||
}
|
||||
}
|
||||
// must NOT change existing behavior:
|
||||
if got := ParseFolderIDs("Movie [imdb-tt1375666]"); got == nil || got.ImdbID != "tt1375666" {
|
||||
if got := ParseFolderIDs("Movie [imdb-tt1375666]", "movie"); got == nil || got.ImdbID != "tt1375666" {
|
||||
t.Errorf("structured imdb regressed: %+v", got)
|
||||
}
|
||||
if got := ParseFolderIDs("Some Movie (2020) [BB]"); got != nil {
|
||||
if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil {
|
||||
t.Errorf("non-tt bracket tag must not parse as id, got %+v", got)
|
||||
}
|
||||
if got := ParseFolderIDs("17 Blocks (2021)"); got != nil {
|
||||
if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil {
|
||||
t.Errorf("no-id folder must return nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,20 +47,13 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit structured provider IDs (e.g. {tmdb-694938}) anchor the group's
|
||||
// identity regardless of how folder and file titles relate, so title
|
||||
// conflicts must not mark the group ambiguous. Renamed releases inside a
|
||||
// tagged folder ("Override (2021) {tmdb-694938}" containing "R.I.A.
|
||||
// (2021).mkv") are the common case.
|
||||
idAnchored := hasStructuredIDAnchor(cleanFilePath, group.ObservedRootPath)
|
||||
|
||||
if group.BaseType == "series" {
|
||||
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, idAnchored, &group)
|
||||
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, &group)
|
||||
} else {
|
||||
populateMovieGroupIdentity(cleanFilePath, assignment, idAnchored, &group)
|
||||
populateMovieGroupIdentity(cleanFilePath, assignment, &group)
|
||||
}
|
||||
|
||||
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath)); ids != nil {
|
||||
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath), group.BaseType); ids != nil {
|
||||
group.TmdbID = ids.TmdbID
|
||||
group.ImdbID = ids.ImdbID
|
||||
group.TvdbID = ids.TvdbID
|
||||
@@ -86,7 +79,7 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
|
||||
return group
|
||||
}
|
||||
|
||||
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
|
||||
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, group *GroupIdentity) {
|
||||
parentDir := filepath.Dir(filePath)
|
||||
parentTitle, parentYear, parentTrusted := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
|
||||
parentTitle = StripComparisonSafeEditionSuffix(parentTitle)
|
||||
@@ -114,12 +107,8 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAn
|
||||
case parentTrusted && relation == titleRelationSoft:
|
||||
reasons = append(reasons, "variant_suffix")
|
||||
case parentTrusted && relation == titleRelationHard:
|
||||
if idAnchored {
|
||||
reasons = append(reasons, "unrelated_title_resolved_by_provider_ids")
|
||||
} else {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "unrelated_title")
|
||||
}
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "unrelated_title")
|
||||
default:
|
||||
if baseTitle == "" {
|
||||
baseTitle = StripComparisonSafeEditionSuffix(stem.Title)
|
||||
@@ -141,7 +130,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAn
|
||||
baseYear = parentYear
|
||||
}
|
||||
|
||||
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted && !idAnchored {
|
||||
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "episode_pattern")
|
||||
}
|
||||
@@ -165,7 +154,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAn
|
||||
})
|
||||
}
|
||||
|
||||
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
|
||||
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, group *GroupIdentity) {
|
||||
ctx := ResolvePathContext(filePath, libraryType)
|
||||
title := assignment.Title
|
||||
year := assignment.Year
|
||||
@@ -190,12 +179,8 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
|
||||
case titleRelationSoft:
|
||||
reasons = append(reasons, "series_alias")
|
||||
case titleRelationHard:
|
||||
if idAnchored {
|
||||
reasons = append(reasons, "series_title_conflict_resolved_by_provider_ids")
|
||||
} else {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_title_conflict")
|
||||
}
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_title_conflict")
|
||||
}
|
||||
if year == 0 {
|
||||
year = observedYear
|
||||
@@ -206,12 +191,8 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
|
||||
case titleRelationSoft:
|
||||
reasons = append(reasons, "series_assignment_soft_conflict")
|
||||
case titleRelationHard:
|
||||
if idAnchored {
|
||||
reasons = append(reasons, "series_assignment_conflict_resolved_by_provider_ids")
|
||||
} else {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_assignment_conflict")
|
||||
}
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_assignment_conflict")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +206,7 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
|
||||
if assignment.HasEpisodePattern && (assignment.HasSeasonStructure || (ctx != nil && ctx.HasSeasonStructure)) && state != "ambiguous" {
|
||||
confidence = "high"
|
||||
}
|
||||
if title == "" && assignment.HasEpisodePattern && !idAnchored {
|
||||
if title == "" && assignment.HasEpisodePattern {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_identity_missing")
|
||||
}
|
||||
@@ -248,19 +229,6 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
|
||||
})
|
||||
}
|
||||
|
||||
// hasStructuredIDAnchor reports whether the observed root folder or the file
|
||||
// name carries explicit provider IDs — structured tags (e.g. {tmdb-694938},
|
||||
// [tvdbid-81189]) or an unambiguous tt-prefixed IMDb id ("Eggs Run (2021)
|
||||
// tt8049994"). ParseFolderIDs returns only such explicit evidence, which is
|
||||
// strong enough to anchor identity over a title conflict.
|
||||
func hasStructuredIDAnchor(filePath, observedRootPath string) bool {
|
||||
if ParseFolderIDs(filepath.Base(observedRootPath)) != nil {
|
||||
return true
|
||||
}
|
||||
baseNoExt := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
return ParseFolderIDs(baseNoExt) != nil
|
||||
}
|
||||
|
||||
func deriveObservedRootPath(filePath, candidateRoot string) string {
|
||||
cleanFilePath := filepath.Clean(filePath)
|
||||
cleanRoot := filepath.Clean(candidateRoot)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
// A renamed release inside a provider-tagged folder must stay resolved: the
|
||||
// structured IDs anchor the identity even though folder and file titles are
|
||||
// unrelated (Radarr keeps the folder name from grab time while the release
|
||||
// inside carries the retitled name).
|
||||
func TestInferGroupIdentity_StructuredIDsResolveMovieTitleConflict(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
|
||||
"movies",
|
||||
RootAssignment{
|
||||
RootPath: "/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}",
|
||||
InferredType: "movie",
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "resolved"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.BaseTitle, "On Fire"; got != want {
|
||||
t.Fatalf("BaseTitle = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.TmdbID, "1196791"; got != want {
|
||||
t.Fatalf("TmdbID = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.ImdbID, "tt28078628"; got != want {
|
||||
t.Fatalf("ImdbID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An unbracketed trailing IMDb id anchors identity the same way a structured
|
||||
// tag does — the tt prefix is unambiguous. Mirrors the dev-library case
|
||||
// "Eggs Run (2021) tt8049994" whose file carries a different release title.
|
||||
func TestInferGroupIdentity_TrailingImdbIDResolvesMovieTitleConflict(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/movies-int/es/Eggs Run (2021) tt8049994/Huevitos Congelados (2022) [WEBDL-1080p]-GRP.mkv",
|
||||
"movies",
|
||||
RootAssignment{
|
||||
RootPath: "/movies-int/es/Eggs Run (2021) tt8049994",
|
||||
InferredType: "movie",
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "resolved"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.ImdbID, "tt8049994"; got != want {
|
||||
t.Fatalf("ImdbID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Without provider IDs the same folder/file title conflict must still be
|
||||
// flagged ambiguous — there is nothing to anchor the identity.
|
||||
func TestInferGroupIdentity_TitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/movies/20s/On Fire (2024)/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
|
||||
"movies",
|
||||
RootAssignment{
|
||||
RootPath: "/movies/20s/On Fire (2024)",
|
||||
InferredType: "movie",
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "ambiguous"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferGroupIdentity_StructuredIDsResolveSeriesTitleConflict(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/tv/Border Control - Sweden (2023) {tvdb-442777}/Season 01/Gränsbevakarna Sverige S01E01.mkv",
|
||||
"series",
|
||||
RootAssignment{
|
||||
RootPath: "/tv/Border Control - Sweden (2023) {tvdb-442777}",
|
||||
InferredType: "series",
|
||||
Title: "Gränsbevakarna Sverige",
|
||||
HasSeasonStructure: true,
|
||||
HasEpisodePattern: true,
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "resolved"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.TvdbID, "442777"; got != want {
|
||||
t.Fatalf("TvdbID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferGroupIdentity_SeriesTitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/tv/Border Control - Sweden (2023)/Season 01/Gränsbevakarna Sverige S01E01.mkv",
|
||||
"series",
|
||||
RootAssignment{
|
||||
RootPath: "/tv/Border Control - Sweden (2023)",
|
||||
InferredType: "series",
|
||||
Title: "Gränsbevakarna Sverige",
|
||||
HasSeasonStructure: true,
|
||||
HasEpisodePattern: true,
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "ambiguous"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func parseInferFolderTitleYear(name string) (string, int, bool) {
|
||||
year, _ := strconv.Atoi(match[2])
|
||||
return strings.TrimSpace(match[1]), year, true
|
||||
}
|
||||
if ParseFolderIDs(name) != nil {
|
||||
if ids := ParseFolderIDs(name, "movie"); ids != nil || ParseFolderIDs(name, "series") != nil {
|
||||
return strings.TrimSpace(surface), 0, true
|
||||
}
|
||||
return strings.TrimSpace(surface), 0, false
|
||||
|
||||
@@ -89,7 +89,7 @@ func InferRootAssignments(
|
||||
Year: assignment.Year,
|
||||
},
|
||||
}
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
|
||||
agg.hasFolderIDs = true
|
||||
agg.root.TmdbID = ids.TmdbID
|
||||
agg.root.ImdbID = ids.ImdbID
|
||||
@@ -254,7 +254,7 @@ func inferFileRootAssignment(
|
||||
}
|
||||
}
|
||||
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
|
||||
assignment.HasFolderIDs = true
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ func detectInferMovieFolderEvidence(parentBase string, nameNoExt string, hasSeas
|
||||
if hasSeasonStructure {
|
||||
return false
|
||||
}
|
||||
if ParseFolderIDs(parentBase) != nil {
|
||||
if ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil {
|
||||
return true
|
||||
}
|
||||
parentTitle, parentYear, trusted := parseInferFolderTitleYear(parentBase)
|
||||
@@ -457,7 +457,7 @@ func promoteCandidateRoot(filePath, currentRoot, title string, year int) (string
|
||||
|
||||
parentBase := filepath.Base(parent)
|
||||
currentBase := filepath.Base(cleanRoot)
|
||||
parentIDs := ParseFolderIDs(parentBase) != nil
|
||||
parentIDs := ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil
|
||||
childTitle, childYear := parseInferTitleYear(currentBase)
|
||||
parentTitle, parentYear := parseInferTitleYear(parentBase)
|
||||
|
||||
|
||||
@@ -31,15 +31,14 @@ type Session struct {
|
||||
TargetBitrateKbps int // requested output bitrate cap for transcodes
|
||||
TranscodeHWAccel string // effective hardware acceleration mode for transcodes
|
||||
|
||||
Position float64
|
||||
IsPaused bool
|
||||
HasWebSocket bool
|
||||
HasRealtimeConnection bool
|
||||
DisableProgressPersistence bool
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
activeTransportCount int
|
||||
Position float64
|
||||
IsPaused bool
|
||||
HasWebSocket bool
|
||||
HasRealtimeConnection bool
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
activeTransportCount int
|
||||
}
|
||||
|
||||
// SessionStreamState stores the mutable stream-specific details that can
|
||||
@@ -372,23 +371,6 @@ func (m *SessionManager) SetRealtimeConnection(sessionID string, connected bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProgressPersistenceDisabled controls whether session progress updates and
|
||||
// stop events should write resume/history state. This is useful for players
|
||||
// whose resume timeline is not the same as the session's file-local timeline.
|
||||
func (m *SessionManager) SetProgressPersistenceDisabled(sessionID string, disabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
s, ok := m.sessions[sessionID]
|
||||
if !ok {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
s.DisableProgressPersistence = disabled
|
||||
m.touchSessionLocked(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchActivity refreshes the session's activity timestamp without changing
|
||||
// any other playback state.
|
||||
func (m *SessionManager) TouchActivity(sessionID string) error {
|
||||
|
||||
@@ -54,11 +54,6 @@ type ScanSourceClient struct {
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type RequestRouterClient struct {
|
||||
client pluginv1.RequestRouterClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type EventConsumerClient struct {
|
||||
client pluginv1.EventConsumerClient
|
||||
timeout time.Duration
|
||||
@@ -153,16 +148,6 @@ func (c *Client) ScanSource(capabilityID string) (*ScanSourceClient, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) RequestRouter(capabilityID string) (*RequestRouterClient, error) {
|
||||
if err := c.requireCapability("request_router.v1", capabilityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RequestRouterClient{
|
||||
client: c.rpc.RequestRouter(),
|
||||
timeout: DefaultRequestRouterTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) EventConsumer(capabilityID string) (*EventConsumerClient, error) {
|
||||
if err := c.requireCapability("event_consumer.v1", capabilityID); err != nil {
|
||||
return nil, err
|
||||
@@ -298,36 +283,6 @@ func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollCh
|
||||
return c.client.PollChanges(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.Fulfill(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.CheckStatus(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ListConfigOptions(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.TestConnection(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.Validate(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *EventConsumerClient) HandleEvent(ctx context.Context, req *pluginv1.HandleEventRequest) (*pluginv1.HandleEventResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -23,9 +23,6 @@ const (
|
||||
DefaultEventTimeout = 10 * time.Second
|
||||
DefaultAuthTimeout = 10 * time.Second
|
||||
DefaultRouteTimeout = 10 * time.Second
|
||||
// DefaultRequestRouterTimeout bounds a single request_router RPC.
|
||||
// Fulfillment hits remote arr instances, so allow generous headroom.
|
||||
DefaultRequestRouterTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
func HandshakeConfig() plugin.HandshakeConfig {
|
||||
|
||||
@@ -10,11 +10,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
@@ -29,7 +27,6 @@ type pluginClient interface {
|
||||
MediaAnalyzer(capabilityID string) (*pluginhost.MediaAnalyzerClient, error)
|
||||
ScheduledTask(capabilityID string) (*pluginhost.ScheduledTaskClient, error)
|
||||
ScanSource(capabilityID string) (*pluginhost.ScanSourceClient, error)
|
||||
RequestRouter(capabilityID string) (*pluginhost.RequestRouterClient, error)
|
||||
EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error)
|
||||
AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error)
|
||||
HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error)
|
||||
@@ -69,7 +66,6 @@ type Service struct {
|
||||
dispatcher *EventDispatcher
|
||||
lifecycleMu sync.RWMutex
|
||||
lifecycleHooks []func(context.Context)
|
||||
launchGroup singleflight.Group
|
||||
}
|
||||
|
||||
// SetEventDispatcher wires the EventDispatcher into the Service. The
|
||||
@@ -486,18 +482,6 @@ func (s *Service) ScanSourceClient(
|
||||
return client.ScanSource(capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) RequestRouterClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
capabilityID string,
|
||||
) (*pluginhost.RequestRouterClient, error) {
|
||||
client, err := s.ensureClient(ctx, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.RequestRouter(capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) ScanSourceClientByPluginID(
|
||||
ctx context.Context,
|
||||
pluginID string,
|
||||
@@ -621,25 +605,7 @@ func (s *Service) ManifestForInstallation(
|
||||
return s.manifestForInstallation(ctx, installationID, false)
|
||||
}
|
||||
|
||||
// ensureClient returns a running client for the installation, collapsing
|
||||
// concurrent first-use of a cold installation into a single launch so a burst of
|
||||
// callers does not spawn redundant plugin processes (Host.Start releases its lock
|
||||
// during the slow launch and cannot dedupe). After the flight completes the key
|
||||
// is freed, so subsequent callers re-run and hit the now-warm cache.
|
||||
func (s *Service) ensureClient(ctx context.Context, installationID int) (pluginClient, error) {
|
||||
v, err, _ := s.launchGroup.Do(strconv.Itoa(installationID), func() (any, error) {
|
||||
// Isolate the shared launch from the leader caller's cancellation: other
|
||||
// waiters depend on this in-flight launch, so a single caller's canceled
|
||||
// request must not tear it down. Values (tracing, auth) are preserved.
|
||||
return s.doEnsureClient(context.WithoutCancel(ctx), installationID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(pluginClient), nil
|
||||
}
|
||||
|
||||
func (s *Service) doEnsureClient(ctx context.Context, installationID int) (pluginClient, error) {
|
||||
installation, err := s.loadInstallation(ctx, installationID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -45,16 +45,6 @@ var runPluginConnectionCheck = func(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
capability := metadataProviderConnectionCheckCapability(manifest, capabilityID)
|
||||
if !metadataProviderSupportsConnectionProbe(capability, "movie") {
|
||||
slog.Debug(
|
||||
"skipping metadata provider connection check for unsupported probe type",
|
||||
"plugin_id", manifest.GetPluginId(),
|
||||
"capability_id", capabilityID,
|
||||
"item_type", "movie",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
metadataClient, err := client.MetadataProvider(capabilityID)
|
||||
if err != nil {
|
||||
@@ -234,66 +224,3 @@ func metadataProviderConnectionCheckCapabilityID(manifest *pluginv1.PluginManife
|
||||
Cause: ErrConnectionTestUnsupported,
|
||||
}
|
||||
}
|
||||
|
||||
func metadataProviderConnectionCheckCapability(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
capabilityID string,
|
||||
) *pluginv1.CapabilityDescriptor {
|
||||
for _, capability := range manifest.GetCapabilities() {
|
||||
if capability.GetType() == "metadata_provider.v1" && capability.GetId() == capabilityID {
|
||||
return capability
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func metadataProviderSupportsConnectionProbe(
|
||||
capability *pluginv1.CapabilityDescriptor,
|
||||
contentType string,
|
||||
) bool {
|
||||
priorities, ok := metadataProviderDefaultPriorities(capability)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return priorities[contentType] > 0
|
||||
}
|
||||
|
||||
func metadataProviderDefaultPriorities(
|
||||
capability *pluginv1.CapabilityDescriptor,
|
||||
) (map[string]float64, bool) {
|
||||
if capability == nil || capability.GetMetadata() == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
metadataMap := capability.GetMetadata().AsMap()
|
||||
raw, ok := metadataMap["default_priority"]
|
||||
if !ok {
|
||||
nested, nestedOK := metadataMap["metadata"].(map[string]any)
|
||||
if !nestedOK {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok = nested["default_priority"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
rawMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
priorities := make(map[string]float64, len(rawMap))
|
||||
for key, value := range rawMap {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
priorities[key] = v
|
||||
case int:
|
||||
priorities[key] = float64(v)
|
||||
case int32:
|
||||
priorities[key] = float64(v)
|
||||
case int64:
|
||||
priorities[key] = float64(v)
|
||||
}
|
||||
}
|
||||
return priorities, len(priorities) > 0
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
type fakeServiceConfigStore struct {
|
||||
@@ -150,35 +149,6 @@ func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginConnectionCheckSkipsMovieProbeForAudiobookOnlyProvider(t *testing.T) {
|
||||
metadata, err := structpb.NewStruct(map[string]any{
|
||||
"default_priority": map[string]any{
|
||||
"audiobook": 2,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStruct() error = %v", err)
|
||||
}
|
||||
|
||||
manifest := connectionTestManifest(t, "silo.audiobook-metadata", "0.1.2")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
{
|
||||
Type: "metadata_provider.v1",
|
||||
Id: "audiobook-metadata",
|
||||
DisplayName: "Audiobook Metadata",
|
||||
Metadata: metadata,
|
||||
},
|
||||
}
|
||||
client := &fakePluginClient{manifest: manifest}
|
||||
|
||||
if err := runPluginConnectionCheck(context.Background(), client, manifest); err != nil {
|
||||
t.Fatalf("runPluginConnectionCheck() error = %v", err)
|
||||
}
|
||||
if client.metadataProviderCalls != 0 {
|
||||
t.Fatalf("metadata provider calls = %d, want 0", client.metadataProviderCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTestGlobalConfigReturnsUnsupportedWithoutStartingPlugin(t *testing.T) {
|
||||
manifest := connectionTestManifest(t, "silo.simple", "0.0.1")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
|
||||
@@ -188,8 +188,7 @@ func (f *fakeServiceHost) Shutdown(context.Context) error {
|
||||
}
|
||||
|
||||
type fakePluginClient struct {
|
||||
manifest *pluginv1.PluginManifest
|
||||
metadataProviderCalls int
|
||||
manifest *pluginv1.PluginManifest
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
|
||||
@@ -197,7 +196,6 @@ func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) MetadataProvider(string) (*pluginhost.MetadataProviderClient, error) {
|
||||
f.metadataProviderCalls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -217,10 +215,6 @@ func (f *fakePluginClient) ScanSource(string) (*pluginhost.ScanSourceClient, err
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) RequestRouter(string) (*pluginhost.RequestRouterClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) EventConsumer(string) (*pluginhost.EventConsumerClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
)
|
||||
|
||||
// countingHerdHost simulates the real Host: Start records the launched client so
|
||||
// a later Client() hits the warm cache; it counts Start calls and can delay each
|
||||
// launch to force concurrent overlap.
|
||||
type countingHerdHost struct {
|
||||
mu sync.Mutex
|
||||
current map[int]pluginClient
|
||||
starts int
|
||||
startResult pluginClient
|
||||
startErr error
|
||||
startDelay time.Duration
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Client(id int) (pluginClient, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if c, ok := h.current[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, pluginhost.ErrClientNotFound
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Start(_ context.Context, req pluginhost.StartRequest) (pluginClient, error) {
|
||||
if h.startDelay > 0 {
|
||||
time.Sleep(h.startDelay)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.starts++
|
||||
if h.startErr != nil {
|
||||
return nil, h.startErr
|
||||
}
|
||||
if h.current == nil {
|
||||
h.current = map[int]pluginClient{}
|
||||
}
|
||||
h.current[req.InstallationID] = h.startResult
|
||||
return h.startResult, nil
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Stop(id int) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.current, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Shutdown(context.Context) error { return nil }
|
||||
|
||||
func (h *countingHerdHost) startCount() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.starts
|
||||
}
|
||||
|
||||
func TestEnsureClientSingleFlightsConcurrentLaunch(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
store := newFakeServiceInstallationStore(&Installation{
|
||||
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
|
||||
InstallPath: installPath, Enabled: true,
|
||||
})
|
||||
host := &countingHerdHost{
|
||||
startResult: &fakePluginClient{manifest: manifest},
|
||||
startDelay: 25 * time.Millisecond,
|
||||
}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
release := make(chan struct{})
|
||||
clients := make([]pluginClient, n)
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-release
|
||||
clients[i], errs[i] = service.ensureClient(context.Background(), 7)
|
||||
}(i)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := host.startCount(); got != 1 {
|
||||
t.Fatalf("host.Start calls = %d, want 1 (singleflighted)", got)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("caller %d err = %v", i, errs[i])
|
||||
}
|
||||
if clients[i] != host.startResult {
|
||||
t.Fatalf("caller %d got %#v, want shared launched client", i, clients[i])
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := service.ensureClient(context.Background(), 7); err != nil {
|
||||
t.Fatalf("post-warm ensureClient err = %v", err)
|
||||
}
|
||||
if got := host.startCount(); got != 1 {
|
||||
t.Fatalf("post-warm host.Start calls = %d, want still 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClientDistinctInstallationsLaunchIndependently(t *testing.T) {
|
||||
mA := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
mB := testPluginManifest(t, "silo.other", "1.0.0")
|
||||
pathA := writeInstalledPluginManifest(t, mA)
|
||||
pathB := writeInstalledPluginManifest(t, mB)
|
||||
store := newFakeServiceInstallationStore(
|
||||
&Installation{ID: 7, PluginID: mA.GetPluginId(), Version: mA.GetVersion(), InstallPath: pathA, Enabled: true},
|
||||
&Installation{ID: 8, PluginID: mB.GetPluginId(), Version: mB.GetVersion(), InstallPath: pathB, Enabled: true},
|
||||
)
|
||||
host := &countingHerdHost{startResult: &fakePluginClient{manifest: mA}, startDelay: 10 * time.Millisecond}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range []int{7, 8, 7, 8} {
|
||||
wg.Add(1)
|
||||
go func(id int) { defer wg.Done(); _, _ = service.ensureClient(context.Background(), id) }(id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := host.startCount(); got != 2 {
|
||||
t.Fatalf("host.Start calls = %d, want 2 (one per installation)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ctxCaptureHost blocks inside Start until released, and records whether the
|
||||
// launch context was canceled. It lets a test cancel the singleflight leader's
|
||||
// ctx while a launch is in flight.
|
||||
type ctxCaptureHost struct {
|
||||
mu sync.Mutex
|
||||
current map[int]pluginClient
|
||||
startResult pluginClient
|
||||
entered chan struct{}
|
||||
proceed chan struct{}
|
||||
sawCancel bool
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Client(id int) (pluginClient, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if c, ok := h.current[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, pluginhost.ErrClientNotFound
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Start(ctx context.Context, req pluginhost.StartRequest) (pluginClient, error) {
|
||||
close(h.entered)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.mu.Lock()
|
||||
h.sawCancel = true
|
||||
h.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
case <-h.proceed:
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.current == nil {
|
||||
h.current = map[int]pluginClient{}
|
||||
}
|
||||
h.current[req.InstallationID] = h.startResult
|
||||
return h.startResult, nil
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Stop(id int) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.current, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Shutdown(context.Context) error { return nil }
|
||||
|
||||
func (h *ctxCaptureHost) canceledDuringLaunch() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sawCancel
|
||||
}
|
||||
|
||||
// TestEnsureClientLaunchIsolatedFromLeaderCancellation pins that a deduped
|
||||
// launch is not coupled to the cancellation of whichever caller happened to be
|
||||
// the singleflight leader: if that caller's request is canceled mid-launch, the
|
||||
// shared launch (which other waiters depend on) must continue.
|
||||
func TestEnsureClientLaunchIsolatedFromLeaderCancellation(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
store := newFakeServiceInstallationStore(&Installation{
|
||||
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
|
||||
InstallPath: installPath, Enabled: true,
|
||||
})
|
||||
host := &ctxCaptureHost{
|
||||
startResult: &fakePluginClient{manifest: manifest},
|
||||
entered: make(chan struct{}),
|
||||
proceed: make(chan struct{}),
|
||||
}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
type result struct {
|
||||
c pluginClient
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, err := service.ensureClient(ctx, 7)
|
||||
done <- result{c, err}
|
||||
}()
|
||||
|
||||
<-host.entered // launch is in flight inside Start
|
||||
cancel() // cancel the leader's ctx mid-launch
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
t.Fatalf("ensureClient returned early after leader cancel (err=%v); launch was coupled to leader ctx", r.err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// still launching — isolated from the leader's cancellation, as required
|
||||
}
|
||||
|
||||
close(host.proceed)
|
||||
r := <-done
|
||||
if r.err != nil {
|
||||
t.Fatalf("ensureClient err = %v, want nil", r.err)
|
||||
}
|
||||
if r.c != host.startResult {
|
||||
t.Fatalf("got %#v, want launched client", r.c)
|
||||
}
|
||||
if host.canceledDuringLaunch() {
|
||||
t.Fatalf("launch ctx was canceled by the leader; expected isolation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClientFailedLaunchPropagatesToAllCallers(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
store := newFakeServiceInstallationStore(&Installation{
|
||||
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
|
||||
InstallPath: installPath, Enabled: true,
|
||||
})
|
||||
host := &countingHerdHost{startErr: context.DeadlineExceeded, startDelay: 15 * time.Millisecond}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
release := make(chan struct{})
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) { defer wg.Done(); <-release; _, errs[i] = service.ensureClient(context.Background(), 7) }(i)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] == nil {
|
||||
t.Fatalf("caller %d err = nil, want the launch error", i)
|
||||
}
|
||||
}
|
||||
if got := host.startCount(); got != 1 {
|
||||
t.Fatalf("failed launch host.Start calls = %d, want 1 (still single-flighted)", got)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
// UIs without reaching back into the browser's localStorage.
|
||||
type UserIdentity struct {
|
||||
Username string
|
||||
Email string
|
||||
ProfileName string
|
||||
ProfileIsPrimary bool
|
||||
}
|
||||
@@ -45,8 +44,8 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p
|
||||
}
|
||||
|
||||
err := l.pool.QueryRow(ctx,
|
||||
"SELECT username, COALESCE(email, '') FROM users WHERE id = $1", userID,
|
||||
).Scan(&out.Username, &out.Email)
|
||||
"SELECT username FROM users WHERE id = $1", userID,
|
||||
).Scan(&out.Username)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, err
|
||||
}
|
||||
@@ -66,22 +65,3 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RequesterIdentityFromLookup adapts a UserIdentityLookup into the requests
|
||||
// service's requester-identity resolver (email + username by user id).
|
||||
type requesterIdentityAdapter struct{ lookup UserIdentityLookup }
|
||||
|
||||
func RequesterIdentityFromLookup(l UserIdentityLookup) *requesterIdentityAdapter {
|
||||
return &requesterIdentityAdapter{lookup: l}
|
||||
}
|
||||
|
||||
func (a *requesterIdentityAdapter) ResolveRequester(ctx context.Context, userID int) (string, string, error) {
|
||||
if a == nil || a.lookup == nil || userID <= 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
id, err := a.lookup.LookupIdentity(ctx, userID, "")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return id.Email, id.Username, nil
|
||||
}
|
||||
|
||||
@@ -35,16 +35,6 @@ func NewMiddleware(perKey RateLimiter, global RateLimiter, store SettingsStore,
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveBackend reports which limiter backend this process is actually
|
||||
// running, as opposed to the configured backend, which only takes effect
|
||||
// after a restart.
|
||||
func (mw *Middleware) ActiveBackend() string {
|
||||
if mw.isMemory {
|
||||
return "memory"
|
||||
}
|
||||
return "redis"
|
||||
}
|
||||
|
||||
// Init loads config and seeds defaults. Call once at startup.
|
||||
func (mw *Middleware) Init(ctx context.Context) error {
|
||||
if err := SeedDefaults(ctx, mw.store); err != nil {
|
||||
|
||||
@@ -90,37 +90,6 @@ func TestApplyMediaTypeFloorAddsAvailableSupplementalType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMediaTypeFloorAddsAvailableAudiobooks(t *testing.T) {
|
||||
items := []ScoredItem{
|
||||
{MediaItemID: "m1", Score: 1.00},
|
||||
{MediaItemID: "m2", Score: 0.99},
|
||||
{MediaItemID: "m3", Score: 0.98},
|
||||
{MediaItemID: "m4", Score: 0.97},
|
||||
{MediaItemID: "m5", Score: 0.96},
|
||||
{MediaItemID: "m6", Score: 0.95},
|
||||
{MediaItemID: "m7", Score: 0.94},
|
||||
{MediaItemID: "m8", Score: 0.93},
|
||||
{MediaItemID: "m9", Score: 0.92},
|
||||
{MediaItemID: "m10", Score: 0.91},
|
||||
}
|
||||
candidates := append([]ScoredItem(nil), items...)
|
||||
candidates = append(candidates,
|
||||
ScoredItem{MediaItemID: "a1", Score: 0.90},
|
||||
ScoredItem{MediaItemID: "a2", Score: 0.89},
|
||||
)
|
||||
mediaTypes := map[string]string{
|
||||
"m1": "movie", "m2": "movie", "m3": "movie", "m4": "movie", "m5": "movie",
|
||||
"m6": "movie", "m7": "movie", "m8": "movie", "m9": "movie", "m10": "movie",
|
||||
"a1": "audiobook", "a2": "audiobook",
|
||||
}
|
||||
|
||||
mixed := applyMediaTypeFloor(items, candidates, mediaTypes)
|
||||
|
||||
if got := countMediaType(mixed, mediaTypes, "audiobook"); got != 2 {
|
||||
t.Fatalf("expected 2 audiobooks from supplemental candidates, got %d in %#v", got, mixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMediaTypeFloorNoopsWithoutSupplementalType(t *testing.T) {
|
||||
items := []ScoredItem{
|
||||
{MediaItemID: "s1", Score: 1.00},
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
const aggregateMediaTypeFloorDivisor = 5
|
||||
|
||||
var aggregateSupplementMediaTypes = []string{"movie", "series", "audiobook"}
|
||||
var aggregateSupplementMediaTypes = []string{"movie", "series"}
|
||||
|
||||
// ForYou returns personalised recommendations grouped by taste clusters.
|
||||
// For cold-start users, non-personalized rows are returned.
|
||||
|
||||
@@ -52,8 +52,8 @@ const tasteSeedCandidateQuery = `
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
|
||||
WHERE (mi.status = 'matched' OR mi.type = 'audiobook')
|
||||
AND mi.type IN ('movie', 'series', 'audiobook')
|
||||
WHERE mi.status = 'matched'
|
||||
AND mi.type IN ('movie', 'series')
|
||||
AND mi.poster_path IS NOT NULL
|
||||
AND mi.poster_path <> ''
|
||||
ORDER BY COALESCE(wc.watch_count, 0) DESC,
|
||||
@@ -279,7 +279,7 @@ func (r *Repo) findTasteProfileCandidates(
|
||||
}
|
||||
|
||||
conditions := []string{
|
||||
"(mi.status = 'matched' OR mi.type = 'audiobook')",
|
||||
"mi.status = 'matched'",
|
||||
"e.media_item_id != ALL($2)",
|
||||
}
|
||||
args := []any{pgvector.NewVector(embedding), excludeIDs}
|
||||
@@ -1223,7 +1223,7 @@ func (r *Repo) GetItemWatchers(ctx context.Context, minWatchers int, maxPerUser
|
||||
|
||||
// --- Cold Start Queries ---
|
||||
|
||||
// GetPopularItems returns the most-played media items over the given number of days.
|
||||
// GetPopularItems returns the most-watched series/movies over the given number of days.
|
||||
// Episodes are resolved to their parent series.
|
||||
func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
@@ -1270,13 +1270,11 @@ func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredIt
|
||||
}
|
||||
|
||||
// GetRecentlyAddedItems returns items added within the given number of days.
|
||||
// Audiobooks bypass the matched-status gate because their scan-derived metadata
|
||||
// is authoritative before any external-provider match exists.
|
||||
func (r *Repo) GetRecentlyAddedItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT content_id, created_at
|
||||
FROM media_items
|
||||
WHERE (status = 'matched' OR type = 'audiobook')
|
||||
WHERE status = 'matched'
|
||||
AND created_at > NOW() - ($1 || ' days')::interval
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
@@ -1327,7 +1325,7 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// GetTasteSeedCandidates returns movie/series/audiobook content IDs ordered for the
|
||||
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
|
||||
// taste-seeding picker: server engagement first (most-watched in the last
|
||||
// 180 days), then rating reliability and rating score, then recency. This keeps
|
||||
// fresh servers from front-loading single-vote TMDB 10.0 obscurities while
|
||||
@@ -1395,7 +1393,7 @@ func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
|
||||
return genres, rows.Err()
|
||||
}
|
||||
|
||||
// GetGenreSamplerItems returns the most-played media items in a specific genre.
|
||||
// GetGenreSamplerItems returns the most-watched series/movies in a specific genre.
|
||||
func (r *Repo) GetGenreSamplerItems(ctx context.Context, genre string, limit int) ([]ScoredItem, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH watched_items AS (
|
||||
@@ -1616,7 +1614,7 @@ func (r *Repo) GetAllUsersWithTasteProfiles(ctx context.Context) ([]StaleProfile
|
||||
return profiles, rows.Err()
|
||||
}
|
||||
|
||||
// GetWatchedItemIDs returns content IDs of media items the user has watched
|
||||
// GetWatchedItemIDs returns content IDs of series/movies the user has watched
|
||||
// (>= 50% progress or completed). Episodes are resolved to their parent series.
|
||||
func (r *Repo) GetWatchedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
|
||||
@@ -24,31 +24,51 @@ type TagResource struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ListRootFolders returns the arr root folders. It yields the local
|
||||
// RootFolderResource shape directly; the schema-driven config form consumes
|
||||
// generic RouterOptions from the plugin instead of arr-specific option types.
|
||||
func ListRootFolders(ctx context.Context, client *Client) ([]RootFolderResource, error) {
|
||||
func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) {
|
||||
var resources []RootFolderResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
out := make([]mediarequests.IntegrationRootFolder, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
out = append(out, mediarequests.IntegrationRootFolder{
|
||||
Path: resource.Path,
|
||||
FreeSpace: resource.FreeSpace,
|
||||
TotalSpace: resource.TotalSpace,
|
||||
Accessible: resource.Accessible,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListQualityProfiles(ctx context.Context, client *Client) ([]QualityProfileResource, error) {
|
||||
func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) {
|
||||
var resources []QualityProfileResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
out := make([]mediarequests.IntegrationQualityProfile, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
out = append(out, mediarequests.IntegrationQualityProfile{
|
||||
ID: resource.ID,
|
||||
Name: resource.Name,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListTags(ctx context.Context, client *Client) ([]TagResource, error) {
|
||||
func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) {
|
||||
var resources []TagResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
out := make([]mediarequests.IntegrationTag, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
out = append(out, mediarequests.IntegrationTag{
|
||||
ID: resource.ID,
|
||||
Label: resource.Label,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AcceptedWithoutResponse returns a FulfillmentResult marking the submission
|
||||
|
||||
@@ -28,16 +28,3 @@ func (e QuotaError) Error() string {
|
||||
func (e QuotaError) Unwrap() error {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
|
||||
// ValidationError carries plugin Validate results back to the API layer.
|
||||
type ValidationError struct {
|
||||
FieldErrors map[string]string
|
||||
FormError string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e.FormError != "" {
|
||||
return e.FormError
|
||||
}
|
||||
return "validation failed"
|
||||
}
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
package requests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
// RequestRouterProvider fulfills a whole request and checks target status via a
|
||||
// request_router.v1 plugin. The host owns governance (which qualities) and the
|
||||
// target records; the provider is the plugin boundary.
|
||||
type RequestRouterProvider interface {
|
||||
Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error)
|
||||
CheckStatus(ctx context.Context, installationID int, capabilityID string, req Request, targets []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error)
|
||||
ListConfigOptions(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (map[string][]RouterOption, error)
|
||||
TestConnection(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (bool, string, error)
|
||||
Validate(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection, siblings []ResolvedRouterConnection) (fieldErrors map[string]string, formError string, err error)
|
||||
}
|
||||
|
||||
// ResolvedRouterConnection is a connection with plaintext credentials + parsed config.
|
||||
type ResolvedRouterConnection struct {
|
||||
ID string
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Config map[string]any
|
||||
}
|
||||
|
||||
type RouterTarget struct {
|
||||
Quality Quality
|
||||
ConnectionID string
|
||||
ExternalID string
|
||||
ExternalStatus string
|
||||
Status Status
|
||||
Message string
|
||||
}
|
||||
|
||||
type RouterTargetRef struct {
|
||||
Quality Quality
|
||||
ConnectionID string
|
||||
ExternalID string
|
||||
}
|
||||
|
||||
type RouterTargetStatus struct {
|
||||
Quality Quality
|
||||
ConnectionID string
|
||||
Status Status
|
||||
ExternalStatus string
|
||||
Message string
|
||||
}
|
||||
|
||||
type RouterOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// RouterClientResolver yields a per-(installation, capability) router client.
|
||||
type RouterClientResolver interface {
|
||||
RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (RouterClient, error)
|
||||
}
|
||||
|
||||
// RouterClient mirrors *pluginhost.RequestRouterClient (exported so the api
|
||||
// package can adapt the concrete type across packages without an import cycle).
|
||||
type RouterClient interface {
|
||||
Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error)
|
||||
CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error)
|
||||
ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error)
|
||||
TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error)
|
||||
Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error)
|
||||
}
|
||||
|
||||
type pluginRouterProvider struct{ resolver RouterClientResolver }
|
||||
|
||||
func NewPluginRouterProvider(r RouterClientResolver) RequestRouterProvider {
|
||||
return &pluginRouterProvider{resolver: r}
|
||||
}
|
||||
|
||||
func routerDescriptor(req Request) *pluginv1.RequestDescriptor {
|
||||
ids := map[string]string{}
|
||||
if req.TMDBID != 0 {
|
||||
ids["tmdb"] = strconv.Itoa(req.TMDBID)
|
||||
}
|
||||
if req.TVDBID != nil {
|
||||
ids["tvdb"] = strconv.Itoa(*req.TVDBID)
|
||||
}
|
||||
if req.IMDbID != "" {
|
||||
ids["imdb"] = req.IMDbID
|
||||
}
|
||||
year := 0
|
||||
if req.Year != nil {
|
||||
year = *req.Year
|
||||
}
|
||||
return &pluginv1.RequestDescriptor{
|
||||
MediaType: string(req.MediaType),
|
||||
Title: req.Title,
|
||||
Year: int32(year),
|
||||
ExternalIds: ids,
|
||||
IsAnime: req.IsAnime,
|
||||
RequesterUserId: int64(req.RequestedByUserID),
|
||||
RequesterProfileId: req.RequestedByProfileID,
|
||||
RequesterEmail: req.RequesterEmail,
|
||||
RequesterUsername: req.RequesterUsername,
|
||||
}
|
||||
}
|
||||
|
||||
func routerProtoConn(c ResolvedRouterConnection) (*pluginv1.RouterConnection, error) {
|
||||
cfg, err := structpb.NewStruct(c.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("router: encode connection %s config: %w", c.ID, err)
|
||||
}
|
||||
return &pluginv1.RouterConnection{Id: c.ID, BaseUrl: c.BaseURL, ApiKey: c.APIKey, Config: cfg}, nil
|
||||
}
|
||||
|
||||
func routerProtoConns(conns []ResolvedRouterConnection) ([]*pluginv1.RouterConnection, error) {
|
||||
out := make([]*pluginv1.RouterConnection, 0, len(conns))
|
||||
for _, c := range conns {
|
||||
pc, err := routerProtoConn(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *pluginRouterProvider) Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) {
|
||||
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
qs := make([]*pluginv1.RequestedQuality, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
qs = append(qs, &pluginv1.RequestedQuality{Id: string(q), Is4K: q == Quality2160p})
|
||||
}
|
||||
pconns, err := routerProtoConns(conns)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := client.Fulfill(ctx, &pluginv1.FulfillRequest{
|
||||
CapabilityId: capabilityID, Request: routerDescriptor(req), Qualities: qs, Connections: pconns,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
targets := make([]RouterTarget, 0, len(resp.GetTargets()))
|
||||
for _, t := range resp.GetTargets() {
|
||||
targets = append(targets, RouterTarget{
|
||||
Quality: Quality(t.GetQuality()), ConnectionID: t.GetConnectionId(),
|
||||
ExternalID: t.GetExternalId(), ExternalStatus: t.GetExternalStatus(),
|
||||
Status: Status(t.GetStatus()), Message: t.GetMessage(),
|
||||
})
|
||||
}
|
||||
return targets, resp.GetMessage(), nil
|
||||
}
|
||||
|
||||
func (p *pluginRouterProvider) CheckStatus(ctx context.Context, installationID int, capabilityID string, req Request, targets []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error) {
|
||||
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := make([]*pluginv1.TargetRef, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
refs = append(refs, &pluginv1.TargetRef{Quality: string(t.Quality), ConnectionId: t.ConnectionID, ExternalId: t.ExternalID})
|
||||
}
|
||||
pconns, err := routerProtoConns(conns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.CheckStatus(ctx, &pluginv1.CheckStatusRequest{
|
||||
CapabilityId: capabilityID, Request: routerDescriptor(req), Targets: refs, Connections: pconns,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RouterTargetStatus, 0, len(resp.GetStatuses()))
|
||||
for _, st := range resp.GetStatuses() {
|
||||
out = append(out, RouterTargetStatus{
|
||||
Quality: Quality(st.GetQuality()), ConnectionID: st.GetConnectionId(),
|
||||
Status: Status(st.GetStatus()), ExternalStatus: st.GetExternalStatus(), Message: st.GetMessage(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *pluginRouterProvider) ListConfigOptions(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (map[string][]RouterOption, error) {
|
||||
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pconn, err := routerProtoConn(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.ListConfigOptions(ctx, &pluginv1.ListConfigOptionsRequest{
|
||||
CapabilityId: capabilityID, Connection: pconn,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]RouterOption{}
|
||||
for field, list := range resp.GetOptionsByField() {
|
||||
opts := make([]RouterOption, 0, len(list.GetOptions()))
|
||||
for _, o := range list.GetOptions() {
|
||||
opts = append(opts, RouterOption{Value: o.GetValue(), Label: o.GetLabel()})
|
||||
}
|
||||
out[field] = opts
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *pluginRouterProvider) TestConnection(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (bool, string, error) {
|
||||
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
pconn, err := routerProtoConn(conn)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
resp, err := client.TestConnection(ctx, &pluginv1.TestConnectionRequest{
|
||||
CapabilityId: capabilityID, Connection: pconn,
|
||||
})
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return resp.GetOk(), resp.GetMessage(), nil
|
||||
}
|
||||
|
||||
func (p *pluginRouterProvider) Validate(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection, siblings []ResolvedRouterConnection) (map[string]string, string, error) {
|
||||
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
pc, err := routerProtoConn(conn)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sibs, err := routerProtoConns(siblings)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := client.Validate(ctx, &pluginv1.ValidateRequest{CapabilityId: capabilityID, Connection: pc, Siblings: sibs})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.GetFieldErrors(), resp.GetFormError(), nil
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package requests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
type fakeRouterClient struct {
|
||||
lastReq *pluginv1.FulfillRequest
|
||||
lastCheckReq *pluginv1.CheckStatusRequest
|
||||
statuses []*pluginv1.TargetStatus
|
||||
optionsByField map[string]*pluginv1.ConfigOptionList
|
||||
validateResp *pluginv1.ValidateResponse
|
||||
}
|
||||
|
||||
func (f *fakeRouterClient) Fulfill(_ context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) {
|
||||
f.lastReq = req
|
||||
return &pluginv1.FulfillResponse{Targets: []*pluginv1.FulfillmentTarget{
|
||||
{Quality: "1080p", ConnectionId: "c1", ExternalId: "7", Status: "queued"},
|
||||
}}, nil
|
||||
}
|
||||
func (f *fakeRouterClient) CheckStatus(_ context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) {
|
||||
f.lastCheckReq = req
|
||||
return &pluginv1.CheckStatusResponse{Statuses: f.statuses}, nil
|
||||
}
|
||||
func (f *fakeRouterClient) ListConfigOptions(context.Context, *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) {
|
||||
return &pluginv1.ListConfigOptionsResponse{OptionsByField: f.optionsByField}, nil
|
||||
}
|
||||
func (f *fakeRouterClient) TestConnection(context.Context, *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) {
|
||||
return &pluginv1.TestConnectionResponse{Ok: true}, nil
|
||||
}
|
||||
func (f *fakeRouterClient) Validate(context.Context, *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) {
|
||||
return f.validateResp, nil
|
||||
}
|
||||
|
||||
type fakeRouterResolver struct{ c RouterClient }
|
||||
|
||||
func (r fakeRouterResolver) RequestRouterClient(context.Context, int, string) (RouterClient, error) {
|
||||
return r.c, nil
|
||||
}
|
||||
|
||||
func TestPluginRouterProviderFulfillTranslates(t *testing.T) {
|
||||
fc := &fakeRouterClient{}
|
||||
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
|
||||
year := 2020
|
||||
req := Request{MediaType: MediaTypeMovie, TMDBID: 42, Title: "X", Year: &year}
|
||||
targets, msg, err := p.Fulfill(context.Background(), 1, "arr", req, []Quality{Quality1080p},
|
||||
[]ResolvedRouterConnection{{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Fulfill: %v", err)
|
||||
}
|
||||
if msg != "" || len(targets) != 1 || targets[0].Status != StatusQueued || targets[0].ExternalID != "7" {
|
||||
t.Fatalf("unexpected result: %+v msg=%q", targets, msg)
|
||||
}
|
||||
gotQ := fc.lastReq.GetQualities()
|
||||
if fc.lastReq.GetRequest().GetExternalIds()["tmdb"] != "42" || len(gotQ) != 1 {
|
||||
t.Fatalf("descriptor/qualities not forwarded: %+v", fc.lastReq)
|
||||
}
|
||||
if gotQ[0].GetId() != "1080p" || gotQ[0].GetIs4K() {
|
||||
t.Fatalf("1080p should be id=1080p is4k=false, got %+v", gotQ[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginRouterProviderCheckStatusTranslates(t *testing.T) {
|
||||
fc := &fakeRouterClient{statuses: []*pluginv1.TargetStatus{
|
||||
{Quality: "1080p", ConnectionId: "c1", Status: "downloading", ExternalStatus: "grabbed", Message: "in queue"},
|
||||
{Quality: "2160p", ConnectionId: "c2", Status: "completed", ExternalStatus: "imported", Message: ""},
|
||||
}}
|
||||
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
|
||||
req := Request{MediaType: MediaTypeMovie, TMDBID: 42, Title: "X"}
|
||||
refs := []RouterTargetRef{
|
||||
{Quality: Quality1080p, ConnectionID: "c1", ExternalID: "7"},
|
||||
{Quality: Quality2160p, ConnectionID: "c2", ExternalID: "8"},
|
||||
}
|
||||
out, err := p.CheckStatus(context.Background(), 1, "arr", req, refs,
|
||||
[]ResolvedRouterConnection{{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("CheckStatus: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d", len(out))
|
||||
}
|
||||
if out[0].Quality != Quality1080p || out[0].ConnectionID != "c1" ||
|
||||
out[0].Status != StatusDownloading || out[0].ExternalStatus != "grabbed" || out[0].Message != "in queue" {
|
||||
t.Fatalf("status[0] mismapped: %+v", out[0])
|
||||
}
|
||||
if out[1].Quality != Quality2160p || out[1].ConnectionID != "c2" || out[1].Status != StatusCompleted || out[1].ExternalStatus != "imported" {
|
||||
t.Fatalf("status[1] mismapped: %+v", out[1])
|
||||
}
|
||||
if got := fc.lastCheckReq.GetTargets(); len(got) != 2 ||
|
||||
got[0].GetConnectionId() != "c1" || got[0].GetExternalId() != "7" ||
|
||||
got[1].GetConnectionId() != "c2" || got[1].GetExternalId() != "8" {
|
||||
t.Fatalf("refs not forwarded: %+v", fc.lastCheckReq.GetTargets())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginRouterProviderListConfigOptionsTranslates(t *testing.T) {
|
||||
fc := &fakeRouterClient{optionsByField: map[string]*pluginv1.ConfigOptionList{
|
||||
"root_folder": {Options: []*pluginv1.ConfigOption{
|
||||
{Value: "/movies", Label: "Movies"},
|
||||
{Value: "/movies4k", Label: "Movies 4K"},
|
||||
}},
|
||||
"quality_profile_id": {Options: []*pluginv1.ConfigOption{
|
||||
{Value: "1", Label: "HD-1080p"},
|
||||
}},
|
||||
}}
|
||||
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
|
||||
out, err := p.ListConfigOptions(context.Background(), 1, "arr",
|
||||
ResolvedRouterConnection{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}})
|
||||
if err != nil {
|
||||
t.Fatalf("ListConfigOptions: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 fields, got %d: %+v", len(out), out)
|
||||
}
|
||||
rf := out["root_folder"]
|
||||
if len(rf) != 2 || rf[0].Value != "/movies" || rf[0].Label != "Movies" || rf[1].Value != "/movies4k" || rf[1].Label != "Movies 4K" {
|
||||
t.Fatalf("root_folder mismapped: %+v", rf)
|
||||
}
|
||||
qp := out["quality_profile_id"]
|
||||
if len(qp) != 1 || qp[0].Value != "1" || qp[0].Label != "HD-1080p" {
|
||||
t.Fatalf("quality_profile_id mismapped: %+v", qp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginRouterProviderValidateTranslates(t *testing.T) {
|
||||
fc := &fakeRouterClient{validateResp: &pluginv1.ValidateResponse{
|
||||
FieldErrors: map[string]string{"is_default": "cannot be 4K"}, FormError: "",
|
||||
}}
|
||||
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
|
||||
fe, form, err := p.Validate(context.Background(), 1, "arr",
|
||||
ResolvedRouterConnection{ID: "c1", Config: map[string]any{"service_kind": "radarr"}},
|
||||
nil)
|
||||
if err != nil || form != "" || fe["is_default"] != "cannot be 4K" {
|
||||
t.Fatalf("unexpected: fe=%v form=%q err=%v", fe, form, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type movieResource struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
TMDBID int `json:"tmdbId,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
TitleSlug string `json:"titleSlug,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
||||
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
MinimumAvailability string `json:"minimumAvailability,omitempty"`
|
||||
Tags []int `json:"tags,omitempty"`
|
||||
AddOptions addMovieOptions `json:"addOptions,omitempty"`
|
||||
}
|
||||
|
||||
type addMovieOptions struct {
|
||||
SearchForMovie bool `json:"searchForMovie"`
|
||||
Monitor string `json:"monitor,omitempty"`
|
||||
}
|
||||
|
||||
func NewClient(httpClient *http.Client) *Client {
|
||||
return &Client{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (c *Client) ListMovieIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
||||
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
|
||||
rootFolders, err := arrclient.ListRootFolders(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags, err := arrclient.ListTags(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mediarequests.IntegrationOptions{
|
||||
Kind: "radarr",
|
||||
RootFolders: rootFolders,
|
||||
QualityProfiles: qualityProfiles,
|
||||
Tags: tags,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
|
||||
if req.MediaType != mediarequests.MediaTypeMovie {
|
||||
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: request is not a movie")
|
||||
}
|
||||
if integration.QualityProfileID == nil {
|
||||
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: quality profile is required")
|
||||
}
|
||||
|
||||
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
|
||||
movie, err := c.lookupMovie(ctx, client, req.TMDBID)
|
||||
if err != nil {
|
||||
return mediarequests.FulfillmentResult{}, err
|
||||
}
|
||||
movie.RootFolderPath = integration.RootFolder
|
||||
movie.QualityProfileID = *integration.QualityProfileID
|
||||
movie.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
|
||||
movie.MinimumAvailability = arrclient.StringOption(integration.Options, "minimum_availability", "released")
|
||||
movie.Tags = integration.Tags
|
||||
movie.AddOptions = addMovieOptions{
|
||||
SearchForMovie: arrclient.BoolOption(
|
||||
integration.Options,
|
||||
"search_for_movie",
|
||||
arrclient.BoolOption(integration.Options, "search_on_add", true),
|
||||
),
|
||||
Monitor: arrclient.StringOption(integration.Options, "monitor", "movieOnly"),
|
||||
}
|
||||
|
||||
var created movieResource
|
||||
if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil {
|
||||
if !arrclient.IsEmptyOrTruncatedDecodeError(err) {
|
||||
return mediarequests.FulfillmentResult{}, err
|
||||
}
|
||||
// POST accepted but Radarr returned an empty body. Recover the
|
||||
// new movie's Radarr ID by listing movies filtered by TMDB ID;
|
||||
// without the ID the reconcile loop cannot advance the request.
|
||||
if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 {
|
||||
return resultFromMovie(found), nil
|
||||
}
|
||||
return arrclient.AcceptedWithoutResponse("radarr"), nil
|
||||
}
|
||||
return resultFromMovie(created), nil
|
||||
}
|
||||
|
||||
func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
|
||||
values := url.Values{}
|
||||
values.Set("tmdbId", strconv.Itoa(tmdbID))
|
||||
var matches []movieResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil {
|
||||
return movieResource{}, err
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.ID > 0 {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID)
|
||||
}
|
||||
|
||||
func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) {
|
||||
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
|
||||
movieID, _ := strconv.Atoi(req.ExternalID)
|
||||
if movieID <= 0 {
|
||||
return mediarequests.FulfillmentStatus{
|
||||
Status: mediarequests.StatusQueued,
|
||||
IntegrationKind: "radarr",
|
||||
ExternalStatus: "external_id_unavailable",
|
||||
}, nil
|
||||
}
|
||||
|
||||
queues, err := c.queueDetails(ctx, client, movieID)
|
||||
if err != nil {
|
||||
return mediarequests.FulfillmentStatus{}, err
|
||||
}
|
||||
evaluation := arrclient.EvaluateQueue(queues)
|
||||
return arrclient.StatusFromQueueEvaluation("radarr", movieID, evaluation), nil
|
||||
}
|
||||
|
||||
func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
|
||||
values := url.Values{}
|
||||
values.Set("tmdbId", strconv.Itoa(tmdbID))
|
||||
// Radarr's /api/v3/movie/lookup/tmdb returns a single MovieResource, unlike
|
||||
// /api/v3/movie/lookup which returns an array. Missing IDs return non-2xx
|
||||
// (handled as HTTPError upstream), so a successful response is the movie.
|
||||
var movie movieResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil {
|
||||
return movieResource{}, err
|
||||
}
|
||||
if movie.TMDBID == 0 {
|
||||
movie.TMDBID = tmdbID
|
||||
}
|
||||
return movie, nil
|
||||
}
|
||||
|
||||
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, movieID int) ([]arrclient.QueueResource, error) {
|
||||
values := url.Values{}
|
||||
values.Set("movieId", strconv.Itoa(movieID))
|
||||
var queues []arrclient.QueueResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return queues, nil
|
||||
}
|
||||
|
||||
func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult {
|
||||
externalID := ""
|
||||
if movie.ID > 0 {
|
||||
externalID = strconv.Itoa(movie.ID)
|
||||
}
|
||||
return mediarequests.FulfillmentResult{
|
||||
IntegrationKind: "radarr",
|
||||
ExternalID: externalID,
|
||||
ExternalStatus: "queued",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
func TestSubmitMovieAddsLookupResult(t *testing.T) {
|
||||
qualityProfileID := 7
|
||||
var posted movieResource
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
|
||||
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/movie":
|
||||
if r.Method == http.MethodGet {
|
||||
if got := r.URL.Query().Get("tmdbId"); got != "550" {
|
||||
t.Fatalf("tmdbId = %q, want 550", got)
|
||||
}
|
||||
w.Write([]byte(`[]`))
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
|
||||
t.Fatalf("decode posted movie: %v", err)
|
||||
}
|
||||
w.Write([]byte(`{"id":42,"tmdbId":550}`))
|
||||
return
|
||||
}
|
||||
case "/api/v3/movie/lookup/tmdb":
|
||||
if got := r.URL.Query().Get("tmdbId"); got != "550" {
|
||||
t.Fatalf("lookup tmdbId = %q, want 550", got)
|
||||
}
|
||||
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
|
||||
MediaType: mediarequests.MediaTypeMovie,
|
||||
TMDBID: 550,
|
||||
Title: "Fight Club",
|
||||
}, mediarequests.Integration{
|
||||
Kind: "radarr",
|
||||
BaseURL: server.URL,
|
||||
APIKeyRef: "radarr-key",
|
||||
RootFolder: "/movies",
|
||||
QualityProfileID: &qualityProfileID,
|
||||
Options: map[string]any{
|
||||
"search_on_add": false,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitMovie returned error: %v", err)
|
||||
}
|
||||
if result.ExternalID != "42" || result.IntegrationKind != "radarr" {
|
||||
t.Fatalf("result = %+v, want radarr external id 42", result)
|
||||
}
|
||||
if posted.RootFolderPath != "/movies" || posted.QualityProfileID != qualityProfileID {
|
||||
t.Fatalf("posted movie = %+v, missing root folder/quality profile", posted)
|
||||
}
|
||||
if posted.AddOptions.SearchForMovie {
|
||||
t.Fatalf("searchForMovie = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) {
|
||||
qualityProfileID := 7
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/movie/lookup/tmdb":
|
||||
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
|
||||
case "/api/v3/movie":
|
||||
if r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
if got := r.URL.Query().Get("tmdbId"); got != "550" {
|
||||
t.Fatalf("tmdbId = %q, want 550", got)
|
||||
}
|
||||
w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`))
|
||||
return
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
|
||||
MediaType: mediarequests.MediaTypeMovie,
|
||||
TMDBID: 550,
|
||||
Title: "Fight Club",
|
||||
}, mediarequests.Integration{
|
||||
Kind: "radarr",
|
||||
BaseURL: server.URL,
|
||||
APIKeyRef: "radarr-key",
|
||||
RootFolder: "/movies",
|
||||
QualityProfileID: &qualityProfileID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitMovie returned error: %v", err)
|
||||
}
|
||||
if result.ExternalID != "99" {
|
||||
t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID)
|
||||
}
|
||||
if result.ExternalStatus != "queued" {
|
||||
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) {
|
||||
qualityProfileID := 7
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/movie/lookup/tmdb":
|
||||
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
|
||||
case "/api/v3/movie":
|
||||
if r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
w.Write([]byte(`[]`))
|
||||
return
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
|
||||
MediaType: mediarequests.MediaTypeMovie,
|
||||
TMDBID: 550,
|
||||
Title: "Fight Club",
|
||||
}, mediarequests.Integration{
|
||||
Kind: "radarr",
|
||||
BaseURL: server.URL,
|
||||
APIKeyRef: "radarr-key",
|
||||
RootFolder: "/movies",
|
||||
QualityProfileID: &qualityProfileID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitMovie returned error: %v", err)
|
||||
}
|
||||
if result.ExternalStatus != "accepted_without_response" {
|
||||
t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMovieStatusReadsQueueDetails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
|
||||
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
|
||||
}
|
||||
if r.URL.Path != "/api/v3/queue/details" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("movieId"); got != "42" {
|
||||
t.Fatalf("movieId = %q, want 42", got)
|
||||
}
|
||||
w.Write([]byte(`[{"movieId":42,"status":"downloading","trackedDownloadState":"downloading"}]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
status, err := client.CheckMovieStatus(context.Background(), mediarequests.Request{
|
||||
MediaType: mediarequests.MediaTypeMovie,
|
||||
TMDBID: 550,
|
||||
ExternalID: "42",
|
||||
}, mediarequests.Integration{
|
||||
Kind: "radarr",
|
||||
BaseURL: server.URL,
|
||||
APIKeyRef: "radarr-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMovieStatus returned error: %v", err)
|
||||
}
|
||||
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/downloading" {
|
||||
t.Fatalf("status = %+v, want downloading", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMovieIntegrationOptionsLoadsChoices(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
|
||||
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
w.Write([]byte(`[{"path":"/movies","freeSpace":123,"totalSpace":456,"accessible":true}]`))
|
||||
case "/api/v3/qualityprofile":
|
||||
w.Write([]byte(`[{"id":7,"name":"HD-1080p"}]`))
|
||||
case "/api/v3/tag":
|
||||
w.Write([]byte(`[{"id":2,"label":"requests"}]`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
options, err := client.ListMovieIntegrationOptions(context.Background(), mediarequests.Integration{
|
||||
Kind: "radarr",
|
||||
BaseURL: server.URL,
|
||||
APIKeyRef: "radarr-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMovieIntegrationOptions returned error: %v", err)
|
||||
}
|
||||
if options.Kind != "radarr" {
|
||||
t.Fatalf("kind = %q, want radarr", options.Kind)
|
||||
}
|
||||
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/movies" {
|
||||
t.Fatalf("root folders = %+v, want /movies", options.RootFolders)
|
||||
}
|
||||
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 7 {
|
||||
t.Fatalf("quality profiles = %+v, want id 7", options.QualityProfiles)
|
||||
}
|
||||
if len(options.Tags) != 1 || options.Tags[0].ID != 2 {
|
||||
t.Fatalf("tags = %+v, want id 2", options.Tags)
|
||||
}
|
||||
}
|
||||
+101
-54
@@ -26,10 +26,14 @@ func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository {
|
||||
return &Repository{pool: pool, cipher: cipher}
|
||||
}
|
||||
|
||||
// apiKeyAAD binds a request_integrations api_key_ref ciphertext to its row.
|
||||
func apiKeyAAD(id string) string {
|
||||
return secret.RowAAD("request_integrations", "api_key_ref", id)
|
||||
}
|
||||
|
||||
// encryptAPIKey encrypts a non-empty, trimmed API key bound to the integration
|
||||
// id. An empty key returns "" so the update path's keep-existing CASE sentinel
|
||||
// still fires (and an absent key stays absent).
|
||||
func (r *Repository) encryptAPIKey(id, apiKey string) (string, error) {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
@@ -467,12 +471,13 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
const integrationColumns = `id, name, enabled, base_url, api_key_ref,
|
||||
last_check_at, last_check_status, last_check_error, updated_at,
|
||||
capability_id, installation_id, supported_media_types, plugin_config`
|
||||
const integrationColumns = `id, kind, name, enabled, base_url, api_key_ref,
|
||||
root_folder, quality_profile_id, tags, is_4k, is_default, is_default_4k,
|
||||
anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
|
||||
options, last_check_at, last_check_status, last_check_error, updated_at`
|
||||
|
||||
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`)
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list request integrations: %w", err)
|
||||
}
|
||||
@@ -517,20 +522,12 @@ func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Int
|
||||
// tx) so the same SQL is reused by the plain create path and the transactional
|
||||
// SaveIntegrationWithDefaults path.
|
||||
func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
|
||||
if i.PluginConfig == nil {
|
||||
i.PluginConfig = map[string]any{}
|
||||
if i.Options == nil {
|
||||
i.Options = map[string]any{}
|
||||
}
|
||||
pluginConfig, err := json.Marshal(i.PluginConfig)
|
||||
options, err := json.Marshal(i.Options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal plugin config: %w", err)
|
||||
}
|
||||
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
|
||||
// upstream in validateInstance; persist it verbatim (never default it to the
|
||||
// capability type, which the plugin runtime can't resolve).
|
||||
capabilityID := strings.TrimSpace(i.CapabilityID)
|
||||
supportedMediaTypes := i.SupportedMediaTypes
|
||||
if supportedMediaTypes == nil {
|
||||
supportedMediaTypes = []string{}
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
|
||||
if err != nil {
|
||||
@@ -538,13 +535,17 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor
|
||||
}
|
||||
row := exec.QueryRow(ctx, `
|
||||
INSERT INTO request_integrations (
|
||||
id, name, enabled, base_url, api_key_ref,
|
||||
capability_id, installation_id, supported_media_types,
|
||||
plugin_config, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now())
|
||||
id, kind, name, enabled, base_url, api_key_ref, root_folder,
|
||||
quality_profile_id, tags, is_4k, is_default, is_default_4k,
|
||||
anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
|
||||
options, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now())
|
||||
RETURNING `+integrationColumns,
|
||||
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
|
||||
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
|
||||
i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
|
||||
apiKeyRef, strings.TrimSpace(i.RootFolder), i.QualityProfileID,
|
||||
int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
|
||||
i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
|
||||
options)
|
||||
out, err := r.scanIntegration(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request integration: %w", err)
|
||||
@@ -555,20 +556,12 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor
|
||||
// updateIntegration runs the integration UPDATE against any executor (pool or
|
||||
// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL.
|
||||
func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
|
||||
if i.PluginConfig == nil {
|
||||
i.PluginConfig = map[string]any{}
|
||||
if i.Options == nil {
|
||||
i.Options = map[string]any{}
|
||||
}
|
||||
pluginConfig, err := json.Marshal(i.PluginConfig)
|
||||
options, err := json.Marshal(i.Options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal plugin config: %w", err)
|
||||
}
|
||||
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
|
||||
// upstream in validateInstance; persist it verbatim (never default it to the
|
||||
// capability type, which the plugin runtime can't resolve).
|
||||
capabilityID := strings.TrimSpace(i.CapabilityID)
|
||||
supportedMediaTypes := i.SupportedMediaTypes
|
||||
if supportedMediaTypes == nil {
|
||||
supportedMediaTypes = []string{}
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
}
|
||||
// Encrypt the incoming key; an empty result preserves the keep-existing CASE
|
||||
// (a blank edit leaves the stored key untouched).
|
||||
@@ -580,12 +573,17 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor
|
||||
UPDATE request_integrations SET
|
||||
name=$2, enabled=$3, base_url=$4,
|
||||
api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
|
||||
capability_id=$6, installation_id=$7,
|
||||
supported_media_types=$8, plugin_config=$9, updated_at=now()
|
||||
root_folder=$6, quality_profile_id=$7, tags=$8, is_4k=$9,
|
||||
is_default=$10, is_default_4k=$11, anime_enabled=$12,
|
||||
anime_quality_profile_id=$13, anime_root_folder=$14, anime_tags=$15,
|
||||
options=$16, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING `+integrationColumns,
|
||||
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
|
||||
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
|
||||
apiKeyRef, strings.TrimSpace(i.RootFolder), i.QualityProfileID,
|
||||
int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
|
||||
i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
|
||||
options)
|
||||
out, err := r.scanIntegration(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -596,10 +594,9 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveIntegrationWithDefaults creates/updates the instance in a single
|
||||
// transaction. The host no longer enforces a single HD/4K default per kind;
|
||||
// the request_router plugin's RouteTargets picks the first is_default
|
||||
// connection from plugin_config, so the save path is a plain insert-or-update.
|
||||
// SaveIntegrationWithDefaults clears the conflicting kind default(s) and
|
||||
// creates/updates the instance in a single transaction so a save failure can
|
||||
// never leave the kind with zero defaults.
|
||||
func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -607,6 +604,17 @@ func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integra
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if in.IsDefault {
|
||||
if err := r.ClearDefault(ctx, tx, in.Kind, false, in.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.IsDefault4K {
|
||||
if err := r.ClearDefault(ctx, tx, in.Kind, true, in.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var out *Integration
|
||||
if isCreate {
|
||||
out, err = r.insertIntegration(ctx, tx, in)
|
||||
@@ -661,6 +669,21 @@ func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearDefault unsets the HD (or 4K) default flag for every instance of a kind
|
||||
// except excludeID, so saving an instance that is itself the new default does
|
||||
// not clear its own freshly-written flag.
|
||||
func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool, excludeID string) error {
|
||||
col := "is_default"
|
||||
if fourK {
|
||||
col = "is_default_4k"
|
||||
}
|
||||
_, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1 AND id <> $2`, kind, excludeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear default: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
|
||||
var actorUserID any
|
||||
if actor.UserID > 0 {
|
||||
@@ -771,13 +794,15 @@ type integrationScanner interface {
|
||||
|
||||
func (r *Repository) scanIntegration(row integrationScanner) (Integration, error) {
|
||||
var i Integration
|
||||
var installationID sql.NullInt64
|
||||
var pluginConfigRaw []byte
|
||||
var quality, animeQuality sql.NullInt64
|
||||
var tags, animeTags []int32
|
||||
var optionsRaw []byte
|
||||
var lastCheckAt sql.NullTime
|
||||
if err := row.Scan(
|
||||
&i.ID, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
|
||||
&lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
|
||||
&i.CapabilityID, &installationID, &i.SupportedMediaTypes, &pluginConfigRaw,
|
||||
&i.ID, &i.Kind, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
|
||||
&i.RootFolder, &quality, &tags, &i.Is4K, &i.IsDefault, &i.IsDefault4K,
|
||||
&i.AnimeEnabled, &animeQuality, &i.AnimeRootFolder, &animeTags,
|
||||
&optionsRaw, &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
|
||||
); err != nil {
|
||||
return Integration{}, err
|
||||
}
|
||||
@@ -789,20 +814,42 @@ func (r *Repository) scanIntegration(row integrationScanner) (Integration, error
|
||||
return Integration{}, fmt.Errorf("decrypt request integration %s api key: %w", i.ID, err)
|
||||
}
|
||||
i.APIKeyRef = apiKey
|
||||
if installationID.Valid {
|
||||
v := int(installationID.Int64)
|
||||
i.InstallationID = &v
|
||||
if quality.Valid {
|
||||
v := int(quality.Int64)
|
||||
i.QualityProfileID = &v
|
||||
}
|
||||
if len(pluginConfigRaw) > 0 {
|
||||
if err := json.Unmarshal(pluginConfigRaw, &i.PluginConfig); err != nil {
|
||||
return Integration{}, fmt.Errorf("unmarshal request integration plugin config for %s: %w", i.ID, err)
|
||||
if animeQuality.Valid {
|
||||
v := int(animeQuality.Int64)
|
||||
i.AnimeQualityProfileID = &v
|
||||
}
|
||||
i.Tags = intsFromInt32(tags)
|
||||
i.AnimeTags = intsFromInt32(animeTags)
|
||||
if len(optionsRaw) > 0 {
|
||||
if err := json.Unmarshal(optionsRaw, &i.Options); err != nil {
|
||||
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err)
|
||||
}
|
||||
}
|
||||
if i.PluginConfig == nil {
|
||||
i.PluginConfig = map[string]any{}
|
||||
if i.Options == nil {
|
||||
i.Options = map[string]any{}
|
||||
}
|
||||
if lastCheckAt.Valid {
|
||||
i.LastCheckAt = &lastCheckAt.Time
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func int32Slice(values []int) []int32 {
|
||||
out := make([]int32, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, int32(value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intsFromInt32(values []int32) []int {
|
||||
out := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, int(value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package requests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
)
|
||||
|
||||
// TestEncryptAPIKeyRoundTripAndAAD guards the security-critical invariants of the
|
||||
// at-rest credential cipher adopted from #95: encryptAPIKey must produce
|
||||
// ciphertext that DecryptIfEncrypted inverts under the SAME id-bound AAD that
|
||||
// secret's backfill writes, a blank key must encrypt to "" (the keep-existing
|
||||
// update sentinel), and the AAD must be row-bound. A drift in apiKeyAAD or the
|
||||
// envelope would silently corrupt or leak every stored connection api key with no
|
||||
// other test catching it (there is no DB harness for scanIntegration).
|
||||
func TestEncryptAPIKeyRoundTripAndAAD(t *testing.T) {
|
||||
cipher, err := secret.New([]byte("unit-test-master-key-with-at-least-32-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("secret.New: %v", err)
|
||||
}
|
||||
r := &Repository{cipher: cipher}
|
||||
|
||||
const id = "integ-1"
|
||||
const key = "00178d9a3893480cbf122d29f7aca1a0"
|
||||
|
||||
enc, err := r.encryptAPIKey(id, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptAPIKey: %v", err)
|
||||
}
|
||||
if enc == "" || enc == key {
|
||||
t.Fatalf("encryptAPIKey did not encrypt: %q", enc)
|
||||
}
|
||||
|
||||
// The AAD must equal what secret.BackfillReferencedColumns wrote, or rows
|
||||
// encrypted by the startup backfill fail to decrypt on read.
|
||||
if got, want := apiKeyAAD(id), secret.RowAAD("request_integrations", "api_key_ref", id); got != want {
|
||||
t.Fatalf("apiKeyAAD = %q, want %q (must match #95 backfill)", got, want)
|
||||
}
|
||||
|
||||
dec, err := cipher.DecryptIfEncrypted(enc, apiKeyAAD(id))
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfEncrypted: %v", err)
|
||||
}
|
||||
if dec != key {
|
||||
t.Fatalf("round-trip = %q, want %q", dec, key)
|
||||
}
|
||||
|
||||
// A blank/whitespace key encrypts to "" so updateIntegration's
|
||||
// CASE WHEN $5 = '' sentinel preserves the stored ciphertext.
|
||||
if blank, err := r.encryptAPIKey(id, " "); err != nil || blank != "" {
|
||||
t.Fatalf("encryptAPIKey(blank) = %q, %v; want \"\", nil (keep-existing sentinel)", blank, err)
|
||||
}
|
||||
|
||||
// AAD is row-bound: ciphertext from one row must not authenticate under
|
||||
// another row's id.
|
||||
if _, err := cipher.DecryptIfEncrypted(enc, apiKeyAAD("integ-2")); err == nil {
|
||||
t.Fatal("ciphertext decrypted under a different id's AAD — AAD is not row-bound")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user