Compare commits

..
Author SHA1 Message Date
Quick fe934b59d3 feat(audiobooks): add first-party audio playback API 2026-06-08 17:32:26 -04:00
219 changed files with 6434 additions and 13046 deletions
+3 -2
View File
@@ -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),
+145
View File
@@ -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.
+1 -1
View File
@@ -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 -2
View File
@@ -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 -33
View File
@@ -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
}
+757
View File
@@ -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
}
+201
View File
@@ -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))
}
-83
View File
@@ -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,
+1 -6
View File
@@ -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,
+3 -16
View File
@@ -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 != "" {
-62
View File
@@ -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}
+25 -107
View File
@@ -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())
}
}
})
}
}
+5 -27
View File
@@ -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"})
}
-140
View File
@@ -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"])
}
}
+53 -39
View File
@@ -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, &quota):
+1 -1
View File
@@ -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
}
+5 -22
View File
@@ -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...)
}
-60
View File
@@ -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
-9
View File
@@ -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",
-36
View File
@@ -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}))
}
-22
View File
@@ -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
View File
@@ -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)
+8 -46
View File
@@ -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.
-44
View File
@@ -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}
+4 -22
View File
@@ -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.
+1 -1
View File
@@ -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
-54
View File
@@ -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
}
-8
View File
@@ -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
-31
View File
@@ -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)
}
}
+5 -4
View File
@@ -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 {
+14 -7
View File
@@ -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) {
+3 -7
View File
@@ -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 {
-36
View File
@@ -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
}
+45 -96
View File
@@ -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
-57
View File
@@ -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)
}
}
}
-117
View File
@@ -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")
}
}
+6 -49
View File
@@ -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" {
+2 -8
View File
@@ -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
+3 -3
View File
@@ -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
}
+1 -9
View File
@@ -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)
}
+2 -8
View File
@@ -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)
-54
View File
@@ -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"}},
-1
View File
@@ -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
-2
View File
@@ -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"`
+10 -12
View File
@@ -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")
}
}
+15 -56
View File
@@ -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())
+2 -49
View File
@@ -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 -72
View File
@@ -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
+1 -1
View File
@@ -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)
-16
View File
@@ -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)
}
})
}
}
+2 -2
View File
@@ -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
-233
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2 -4
View File
@@ -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,
-36
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
-18
View File
@@ -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)
}
})
}
}
+4 -5
View File
@@ -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 {
+2 -1
View File
@@ -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},
}
}
+5 -5
View File
@@ -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 &copy
}
+21 -66
View File
@@ -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
-59
View File
@@ -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)
}
})
}
}
+2 -26
View File
@@ -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)
}
+6 -2
View File
@@ -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
View File
@@ -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"
}
+21 -48
View File
@@ -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)
}
}
+4 -4
View File
@@ -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)
}
}
+13 -45
View File
@@ -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)
-108
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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)
+8 -26
View File
@@ -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 {
-45
View File
@@ -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()
-3
View File
@@ -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 {
-34
View File
@@ -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
-73
View File
@@ -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{
+1 -7
View File
@@ -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)
}
}
+2 -22
View File
@@ -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
}
-10
View File
@@ -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 {
-31
View File
@@ -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},
+1 -1
View File
@@ -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.
+8 -10
View File
@@ -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, `
+29 -9
View File
@@ -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
-13
View File
@@ -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"
}
-249
View File
@@ -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
}
-139
View File
@@ -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)
}
}
+176
View File
@@ -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",
}
}
+236
View File
@@ -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
View File
@@ -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