Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712969d351 | ||
|
|
9ada640fcc | ||
|
|
296d366af9 | ||
|
|
46807e079c | ||
|
|
2fe2c918f4 | ||
|
|
adbdd86ac5 | ||
|
|
b9a9613fd0 | ||
|
|
cb6272ef25 | ||
|
|
89f005dfa8 | ||
|
|
2c714e4ef2 | ||
|
|
66a4146fc2 | ||
|
|
b7439884ec | ||
|
|
7c64c06e3d | ||
|
|
2fa30379ad | ||
|
|
5855cebe75 | ||
|
|
cf4e080bf4 | ||
|
|
339ed20074 |
+2
-3
@@ -72,8 +72,6 @@ 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"
|
||||
@@ -1541,7 +1539,8 @@ func main() {
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
|
||||
@@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
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/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,6 +1,10 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
@@ -22,3 +26,31 @@ 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
|
||||
}
|
||||
|
||||
@@ -225,6 +225,9 @@ 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.
|
||||
@@ -891,10 +894,90 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
case "author", "narrator", "series":
|
||||
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listAudiobookSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
contentIDs := uniqueItemContentIDs(items)
|
||||
if len(contentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var query string
|
||||
switch sortField {
|
||||
case "author":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
|
||||
case "narrator":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
|
||||
case "series":
|
||||
query = `
|
||||
SELECT target.content_id, BTRIM(s.series_name) AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN audiobook_series s ON s.content_id = target.content_id
|
||||
WHERE BTRIM(s.series_name) <> ''`
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var contentID, value string
|
||||
if err := rows.Scan(&contentID, &value); err != nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if contentID == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
resp := metrics[contentID]
|
||||
if resp == nil {
|
||||
resp = &sortMetricsResponse{}
|
||||
metrics[contentID] = resp
|
||||
}
|
||||
switch sortField {
|
||||
case "author":
|
||||
resp.Author = value
|
||||
case "narrator":
|
||||
resp.Narrator = value
|
||||
case "series":
|
||||
resp.SeriesName = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPersonSortMetricQuery(kind int) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT target.content_id, person.name AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN LATERAL (
|
||||
SELECT BTRIM(p.name) AS name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = target.content_id
|
||||
AND ip.kind = %d
|
||||
AND p.name IS NOT NULL
|
||||
AND BTRIM(p.name) <> ''
|
||||
ORDER BY p.name ASC
|
||||
LIMIT 1
|
||||
) person ON TRUE`, kind)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listUserSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
|
||||
@@ -574,6 +574,11 @@ 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)
|
||||
}
|
||||
@@ -2055,7 +2060,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, level)
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
|
||||
if defaultPriority > 0 {
|
||||
candidates = append(candidates, candidate{
|
||||
installationID: c.PluginInstallationID,
|
||||
|
||||
@@ -49,6 +49,8 @@ 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)
|
||||
}
|
||||
@@ -257,6 +259,7 @@ 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"`
|
||||
@@ -761,6 +764,9 @@ 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)
|
||||
@@ -799,7 +805,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
if session == nil || session.Position <= 0 {
|
||||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
|
||||
@@ -1281,6 +1287,13 @@ 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)
|
||||
@@ -1320,7 +1333,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
h.restoreSessionProgress(r.Context(), session, file)
|
||||
}
|
||||
if h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
targetID := playbackProgressTarget(effectiveFile)
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||||
@@ -1549,7 +1562,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 h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
|
||||
targetID := playbackProgressTarget(file)
|
||||
if targetID != "" {
|
||||
|
||||
@@ -129,6 +129,10 @@ 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 }
|
||||
@@ -283,6 +287,64 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil {
|
||||
t.Fatalf("seed progress: %v", err)
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "book-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "book.m4b"),
|
||||
Duration: 600,
|
||||
}
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != 201 {
|
||||
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp playbackSessionResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`))
|
||||
progressReq = progressReq.WithContext(newAuthorizedPlaybackContext())
|
||||
progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID)
|
||||
progressRR := httptest.NewRecorder()
|
||||
handler.HandleUpdateProgress(progressRR, progressReq)
|
||||
if progressRR.Code != 204 {
|
||||
t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String())
|
||||
}
|
||||
|
||||
stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil)
|
||||
stopReq = stopReq.WithContext(newAuthorizedPlaybackContext())
|
||||
stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID)
|
||||
stopRR := httptest.NewRecorder()
|
||||
handler.HandleStopPlayback(stopRR, stopReq)
|
||||
if stopRR.Code != 204 {
|
||||
t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 {
|
||||
t.Fatalf("progress after disabled session = %+v, want 500/1200", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
|
||||
file := &models.MediaFile{Duration: 3600}
|
||||
|
||||
|
||||
@@ -169,22 +169,27 @@ type pluginConfigSchemaJSON struct {
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type pluginAdminFormOptionJSON struct {
|
||||
@@ -193,6 +198,31 @@ 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"`
|
||||
@@ -660,7 +690,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, level)
|
||||
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
|
||||
}); err != nil {
|
||||
slog.Warn("failed to append provider to library chains",
|
||||
"installation_id", installation.ID,
|
||||
@@ -1342,26 +1372,78 @@ 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(),
|
||||
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()),
|
||||
})
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
|
||||
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
|
||||
// this, the schema-driven request-connection form silently loses its
|
||||
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
|
||||
// validation, sections, and the MULTI_SELECT control mapping, which makes the
|
||||
// new form non-functional (required fields can never be set, save stays
|
||||
// disabled).
|
||||
func TestAdminFormToJSONSchemaFields(t *testing.T) {
|
||||
descriptor := &pluginv1.AdminFormDescriptor{
|
||||
SubmitLabel: "Connect",
|
||||
Fields: []*pluginv1.AdminFormField{
|
||||
{
|
||||
Key: "quality_profile_ids",
|
||||
Label: "Quality Profiles",
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
|
||||
DynamicOptions: true,
|
||||
ExclusiveGroupField: "service_kind",
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
|
||||
},
|
||||
Validation: &pluginv1.AdminFormValidation{
|
||||
HasMin: true,
|
||||
Min: 1,
|
||||
HasMax: true,
|
||||
Max: 10,
|
||||
Pattern: "^[0-9]+$",
|
||||
MinLength: 1,
|
||||
MaxLength: 64,
|
||||
},
|
||||
},
|
||||
},
|
||||
Sections: []*pluginv1.AdminFormSection{
|
||||
{
|
||||
Key: "advanced",
|
||||
Title: "Advanced",
|
||||
Description: "Optional tuning",
|
||||
Collapsible: true,
|
||||
CollapsedDefault: true,
|
||||
FieldKeys: []string{"quality_profile_ids"},
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
form := adminFormToJSON(descriptor)
|
||||
if form == nil {
|
||||
t.Fatal("adminFormToJSON returned nil")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal admin form: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Fields []struct {
|
||||
Control string `json:"control"`
|
||||
DynamicOptions bool `json:"dynamic_options"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
Validation *struct {
|
||||
HasMin bool `json:"has_min"`
|
||||
Min float64 `json:"min"`
|
||||
HasMax bool `json:"has_max"`
|
||||
Max float64 `json:"max"`
|
||||
Pattern string `json:"pattern"`
|
||||
MinLength int32 `json:"min_length"`
|
||||
MaxLength int32 `json:"max_length"`
|
||||
} `json:"validation"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field"`
|
||||
} `json:"fields"`
|
||||
Sections []struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
|
||||
}
|
||||
|
||||
if len(decoded.Fields) != 1 {
|
||||
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
|
||||
}
|
||||
field := decoded.Fields[0]
|
||||
|
||||
// Control must serialize to the SHORT form the frontend switches on.
|
||||
if field.Control != "MULTI_SELECT" {
|
||||
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
|
||||
}
|
||||
if !field.DynamicOptions {
|
||||
t.Error("dynamic_options = false, want true")
|
||||
}
|
||||
if field.ExclusiveGroupField != "service_kind" {
|
||||
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
|
||||
}
|
||||
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
|
||||
len(field.ShowWhen[0].Equals) != 2 {
|
||||
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
|
||||
}
|
||||
if field.Validation == nil {
|
||||
t.Fatal("validation not serialized")
|
||||
}
|
||||
if !field.Validation.HasMin || field.Validation.Min != 1 ||
|
||||
!field.Validation.HasMax || field.Validation.Max != 10 ||
|
||||
field.Validation.Pattern != "^[0-9]+$" ||
|
||||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
|
||||
t.Errorf("validation not serialized correctly: %+v", field.Validation)
|
||||
}
|
||||
|
||||
if len(decoded.Sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
|
||||
}
|
||||
section := decoded.Sections[0]
|
||||
if section.Key != "advanced" || section.Title != "Advanced" ||
|
||||
section.Description != "Optional tuning" || !section.Collapsible ||
|
||||
!section.CollapsedDefault {
|
||||
t.Errorf("section scalar fields not serialized correctly: %+v", section)
|
||||
}
|
||||
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
|
||||
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
|
||||
}
|
||||
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
|
||||
len(section.ShowWhen[0].Equals) != 1 {
|
||||
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
|
||||
}
|
||||
}
|
||||
|
||||
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
|
||||
// renames. The DTO field names currently mirror the proto field names 1:1, so
|
||||
// this is empty; add an entry only when a DTO field is deliberately named
|
||||
// differently from its proto source (the test then accepts the renamed target).
|
||||
var adminFormDTOMappingRenames = map[string]string{}
|
||||
|
||||
// protoInternalFields are the protobuf-runtime bookkeeping fields every
|
||||
// generated message struct carries. They are never serialized to the client
|
||||
// DTO, so the completeness guard skips them.
|
||||
var protoInternalFields = map[string]bool{
|
||||
"state": true,
|
||||
"sizeCache": true,
|
||||
"unknownFields": true,
|
||||
}
|
||||
|
||||
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
|
||||
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
|
||||
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
|
||||
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
|
||||
// later. It reflects over each proto message's exported fields and asserts the
|
||||
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
|
||||
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
|
||||
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
|
||||
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
|
||||
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
|
||||
// risky; this guard is the proportionate fix.)
|
||||
func TestAdminFormDTOCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
proto reflect.Type
|
||||
dto reflect.Type
|
||||
}{
|
||||
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
|
||||
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
|
||||
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dtoFields := map[string]bool{}
|
||||
for i := 0; i < tc.dto.NumField(); i++ {
|
||||
dtoFields[tc.dto.Field(i).Name] = true
|
||||
}
|
||||
|
||||
for i := 0; i < tc.proto.NumField(); i++ {
|
||||
pf := tc.proto.Field(i)
|
||||
if !pf.IsExported() {
|
||||
continue // unexported protobuf-internal state
|
||||
}
|
||||
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
|
||||
continue
|
||||
}
|
||||
want := pf.Name
|
||||
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
|
||||
want = mapped
|
||||
}
|
||||
if !dtoFields[want] {
|
||||
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
|
||||
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
|
||||
"(or record an intentional rename in adminFormDTOMappingRenames).",
|
||||
tc.name, pf.Name, want, tc.dto.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ 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 {
|
||||
@@ -64,6 +71,10 @@ 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{
|
||||
@@ -151,10 +162,21 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Publish for multi-instance reload (if EventBus is available/backed by Redis)
|
||||
@@ -165,5 +187,5 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
)
|
||||
|
||||
type fakeRateLimitStore struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func newFakeRateLimitStore() *fakeRateLimitStore {
|
||||
return &fakeRateLimitStore{values: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
|
||||
s.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(s.values))
|
||||
for k, v := range s.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
|
||||
t.Helper()
|
||||
perKey := ratelimit.NewMemoryLimiter()
|
||||
global := ratelimit.NewMemoryLimiter()
|
||||
t.Cleanup(func() {
|
||||
perKey.Close()
|
||||
global.Close()
|
||||
})
|
||||
mw := ratelimit.NewMiddleware(perKey, global, store, true)
|
||||
if err := mw.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init middleware: %v", err)
|
||||
}
|
||||
return mw
|
||||
}
|
||||
|
||||
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUpdateConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode PUT response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp rateLimitConfigResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode GET response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
|
||||
// The limiter is nil when rate limiting was disabled at boot. The config
|
||||
// endpoints must still work so an admin can re-enable it from the UI.
|
||||
store := newFakeRateLimitStore()
|
||||
store.values["ratelimit.enabled"] = "false"
|
||||
h := NewRateLimitHandler(store, nil, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if got.Enabled {
|
||||
t.Error("GET enabled = true, want false")
|
||||
}
|
||||
if got.Active {
|
||||
t.Error("GET active = true, want false when no limiter is running")
|
||||
}
|
||||
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
if store.values["ratelimit.enabled"] != "true" {
|
||||
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
|
||||
}
|
||||
if store.values["ratelimit.backend"] != "redis" {
|
||||
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
|
||||
}
|
||||
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
|
||||
store := newFakeRateLimitStore()
|
||||
mw := newRunningRateLimitMiddleware(t, store)
|
||||
h := NewRateLimitHandler(store, mw, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if !got.Active {
|
||||
t.Error("GET active = false, want true with a running limiter")
|
||||
}
|
||||
if got.ActiveBackend != "memory" {
|
||||
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
|
||||
}
|
||||
|
||||
// Toggling enabled hot-reloads; no restart needed when the backend matches.
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
|
||||
// Switching backend only takes effect at boot.
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type RequestService interface {
|
||||
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error)
|
||||
|
||||
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
@@ -588,62 +588,36 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
|
||||
}
|
||||
|
||||
type requestIntegrationResponse struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +630,18 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI
|
||||
}
|
||||
|
||||
func writeRequestServiceError(w http.ResponseWriter, err error) {
|
||||
// Plugin/instance validation failures carry inline field/form errors; surface
|
||||
// them as a structured 400 so any handler routing through here renders them
|
||||
// inline. Checked first because *ValidationError does not wrap a sentinel.
|
||||
var verr *mediarequests.ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "validation_failed",
|
||||
"field_errors": verr.FieldErrors,
|
||||
"form_error": verr.FormError,
|
||||
})
|
||||
return
|
||||
}
|
||||
var quota mediarequests.QuotaError
|
||||
switch {
|
||||
case errors.As(err, "a):
|
||||
|
||||
@@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -157,8 +157,11 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
|
||||
}
|
||||
|
||||
cfg := sections.ParseConfigFilters(config)
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
|
||||
return "filter_type must be 'movie' or 'series'", false
|
||||
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
|
||||
}
|
||||
|
||||
var rawLibraryConfig struct {
|
||||
@@ -1408,8 +1411,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
|
||||
return resolved
|
||||
}
|
||||
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after
|
||||
// SectionContinueWatching in the resolved sections list.
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry after the
|
||||
// contiguous continue rows that start with the video Continue Watching row.
|
||||
func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection {
|
||||
nextUp := sections.ResolvedSection{
|
||||
ID: "system-next-up",
|
||||
@@ -1418,6 +1421,20 @@ 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)
|
||||
@@ -1428,7 +1445,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
}
|
||||
}
|
||||
|
||||
// If no continue_watching found, prepend
|
||||
// If no resume row is found, prepend.
|
||||
return append([]sections.ResolvedSection{nextUp}, resolved...)
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,66 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) {
|
||||
tests := []string{
|
||||
`{"continue_type":"watching"}`,
|
||||
`{"continue_type":"listening"}`,
|
||||
`{"continue_type":"reading"}`,
|
||||
`{"filter_type":"audiobook"}`,
|
||||
}
|
||||
|
||||
for _, config := range tests {
|
||||
t.Run(config, func(t *testing.T) {
|
||||
if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok {
|
||||
t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) {
|
||||
msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`))
|
||||
if ok {
|
||||
t.Fatal("validateSectionConfig accepted unknown continue_type")
|
||||
}
|
||||
if msg != "continue_type must be 'watching', 'listening', or 'reading'" {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) {
|
||||
in := []sections.ResolvedSection{
|
||||
{
|
||||
ID: "cw",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Watching",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
|
||||
},
|
||||
{
|
||||
ID: "cl",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Listening",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeListening),
|
||||
},
|
||||
{ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"},
|
||||
}
|
||||
|
||||
got := injectNextUpSection(in)
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, section := range got {
|
||||
gotIDs = append(gotIDs, section.ID)
|
||||
}
|
||||
wantIDs := []string{"cw", "cl", "system-next-up", "recent"}
|
||||
if len(gotIDs) != len(wantIDs) {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if gotIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) {
|
||||
in := []sections.SectionWithItems{
|
||||
// empty seasonal — drop
|
||||
|
||||
@@ -20,6 +20,7 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
|
||||
const (
|
||||
libraryPageStateSettingKey = "ui.library_page_state"
|
||||
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
|
||||
searchMediaScopeSettingKey = "search.media_scope"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -171,6 +172,14 @@ 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",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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}))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,6 @@ 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"
|
||||
@@ -441,7 +439,8 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
tmdb.NewClient(tmdbAPIKey, 40),
|
||||
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
|
||||
)
|
||||
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
AttachRequestRouter(requestSvc, deps.PluginService)
|
||||
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
if viewerResolver != nil {
|
||||
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
|
||||
}
|
||||
@@ -2157,8 +2156,11 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Rate limit admin routes
|
||||
if deps.RateLimitMW != nil && settingsRepo != nil {
|
||||
// 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 {
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
|
||||
r.Route("/rate-limits", func(r chi.Router) {
|
||||
r.Get("/config", rateLimitHandler.HandleGetConfig)
|
||||
|
||||
@@ -44,7 +44,8 @@ const (
|
||||
|
||||
// defaultEnrichBatchSize is the maximum number of audiobook items processed
|
||||
// per sweep invocation. Keeps latency bounded for large libraries.
|
||||
defaultEnrichBatchSize = 50
|
||||
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
|
||||
defaultEnrichBatchSize = 250
|
||||
// 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.
|
||||
@@ -52,18 +53,28 @@ 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 defaultEnrichBatchSize so workers never outnumber the
|
||||
// batch they drain.
|
||||
func audiobookEnrichWorkers() int {
|
||||
// workers, capped to the active batch size so workers never outnumber the batch
|
||||
// they drain.
|
||||
func audiobookEnrichWorkers(batchSize int) 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 n > defaultEnrichBatchSize {
|
||||
n = defaultEnrichBatchSize
|
||||
if batchSize > 0 && n > batchSize {
|
||||
n = batchSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -105,6 +116,7 @@ func NewEnricher(
|
||||
personRepo *catalog.PersonRepository,
|
||||
providerIDs *catalog.ProviderIDRepository,
|
||||
) *Enricher {
|
||||
batchSize := audiobookEnrichBatchSize()
|
||||
return &Enricher{
|
||||
pool: pool,
|
||||
chainRepo: chainRepo,
|
||||
@@ -112,8 +124,8 @@ func NewEnricher(
|
||||
itemRepo: itemRepo,
|
||||
personRepo: personRepo,
|
||||
providerIDs: providerIDs,
|
||||
batchSize: defaultEnrichBatchSize,
|
||||
workers: audiobookEnrichWorkers(),
|
||||
batchSize: batchSize,
|
||||
workers: audiobookEnrichWorkers(batchSize),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +178,32 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
return enriched, nil
|
||||
}
|
||||
|
||||
// HasPendingItems reports whether a scheduled sweep has any audiobook rows to
|
||||
// process. It mirrors claimBatch's eligibility predicate without loading rows
|
||||
// or provider IDs, so the scheduler can skip no-op executions without adding
|
||||
// task-history noise.
|
||||
func (e *Enricher) HasPendingItems(ctx context.Context) (bool, error) {
|
||||
if e == nil || e.pool == nil || e.chainRepo == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := e.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM media_items mi
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND (mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
AND mi.last_refreshed IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
`).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking pending audiobook enrichment: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// runBatch processes items in parallel using e.workers goroutines. The
|
||||
// enrichFn parameter exists for testing: production calls pass e.enrichItem.
|
||||
// Returns the count of items where enrichFn returned nil.
|
||||
|
||||
@@ -63,6 +63,50 @@ func TestEnricherRunFansOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
|
||||
|
||||
e := NewEnricher(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if e.batchSize != 123 {
|
||||
t.Fatalf("batchSize = %d, want 123", e.batchSize)
|
||||
}
|
||||
if e.workers != 8 {
|
||||
t.Fatalf("workers = %d, want 8", e.workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
|
||||
|
||||
e := NewEnricher(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if e.batchSize != 50 {
|
||||
t.Fatalf("batchSize = %d, want 50", e.batchSize)
|
||||
}
|
||||
if e.workers != e.batchSize {
|
||||
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
|
||||
|
||||
if got := audiobookEnrichWorkers(3); got != 3 {
|
||||
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
|
||||
|
||||
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
|
||||
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
|
||||
cacher := &fakeAudiobookImageCacher{}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
|
||||
@@ -106,6 +106,23 @@ 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
|
||||
@@ -118,10 +135,7 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
now := time.Now()
|
||||
attempted := 0
|
||||
for _, f := range feeds {
|
||||
if f.FeedURL == "" {
|
||||
continue
|
||||
}
|
||||
if !isDue(f, now) {
|
||||
if !shouldRefreshFeed(f, now) {
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
@@ -136,6 +150,10 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
return attempted, nil
|
||||
}
|
||||
|
||||
func shouldRefreshFeed(f PodcastFeed, now time.Time) bool {
|
||||
return f.FeedURL != "" && isDue(f, now)
|
||||
}
|
||||
|
||||
// RefreshOne refreshes a single podcast feed. Public so the admin
|
||||
// force-refresh endpoint can call it directly. Records success or failure
|
||||
// in podcast_feeds.last_refresh_error regardless of outcome.
|
||||
|
||||
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
|
||||
if deps.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
return deps.Detail.PresignURL(ctx, path, variant)
|
||||
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
|
||||
},
|
||||
})
|
||||
s.ABSHandler = h
|
||||
|
||||
@@ -3,7 +3,14 @@ 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 {
|
||||
@@ -74,3 +81,50 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
|
||||
resolver := &recordingImageResolver{}
|
||||
detail := &catalog.DetailService{}
|
||||
detail.SetImageResolver(resolver)
|
||||
|
||||
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
|
||||
Items: &catalog.ItemRepository{},
|
||||
Files: &scanner.FileRepository{},
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
coverResolver := absCoverResolverForTest(t, handler)
|
||||
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
|
||||
|
||||
if !strings.Contains(got, "/w500.webp") {
|
||||
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
|
||||
}
|
||||
if resolver.variant != "featured" {
|
||||
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
|
||||
}
|
||||
}
|
||||
|
||||
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
|
||||
t.Helper()
|
||||
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
|
||||
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
|
||||
}
|
||||
|
||||
type recordingImageResolver struct {
|
||||
path string
|
||||
variant string
|
||||
}
|
||||
|
||||
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
|
||||
r.path = path
|
||||
r.variant = variant
|
||||
return "resolved://" + path
|
||||
}
|
||||
|
||||
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
|
||||
out := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
out[path] = r.ResolveImageURL(context.Background(), path, variant)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ 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.
|
||||
@@ -295,6 +296,13 @@ 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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter
|
||||
// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into
|
||||
// the WHERE clause, and is absent otherwise. Guards against a future refactor
|
||||
// of buildBrowsePlan silently dropping the condition.
|
||||
func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) {
|
||||
const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL"
|
||||
repo := &BrowseRepository{}
|
||||
|
||||
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true})
|
||||
if err != nil || earlyEmpty {
|
||||
t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty)
|
||||
}
|
||||
if !strings.Contains(plan.whereClause, predicate) {
|
||||
t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
|
||||
plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"})
|
||||
if err != nil {
|
||||
t.Fatalf("buildBrowsePlan err=%v", err)
|
||||
}
|
||||
if strings.Contains(plan.whereClause, predicate) {
|
||||
t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
}
|
||||
@@ -389,12 +389,11 @@ func normalizeCatalogMatch(raw string) string {
|
||||
}
|
||||
|
||||
func parseCatalogMediaScope(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "movie", "series", "episode", "audiobook":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return ""
|
||||
scope := strings.ToLower(strings.TrimSpace(raw))
|
||||
if scope != "" && IsValidMediaScope(scope) {
|
||||
return scope
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultCatalogQuerySort(searchQuery string) QuerySort {
|
||||
|
||||
@@ -1086,12 +1086,8 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
|
||||
}
|
||||
|
||||
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
|
||||
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 !IsValidMediaScope(def.MediaScope) {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'", ErrInvalidCatalogRequest)
|
||||
}
|
||||
if def.Match != "" && def.Match != "all" && def.Match != "any" {
|
||||
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
|
||||
@@ -1607,12 +1603,7 @@ func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter,
|
||||
MaxContentRating: access.MaxContentRating,
|
||||
}
|
||||
|
||||
var itemTypes []string
|
||||
if req.Query.MediaScope != "" {
|
||||
itemTypes = []string{req.Query.MediaScope}
|
||||
}
|
||||
|
||||
return searchAccess, itemTypes, false
|
||||
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
|
||||
}
|
||||
|
||||
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
|
||||
@@ -1622,7 +1613,9 @@ func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilter
|
||||
}
|
||||
|
||||
filters := BrowseFilters{
|
||||
Type: req.Query.MediaScope,
|
||||
// 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), ","),
|
||||
NamePrefix: req.NamePrefix,
|
||||
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
|
||||
MaxContentRating: access.MaxContentRating,
|
||||
@@ -1763,7 +1756,7 @@ func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*model
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if def.MediaScope != "" && !strings.EqualFold(strings.TrimSpace(item.Type), def.MediaScope) {
|
||||
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
|
||||
continue
|
||||
}
|
||||
if catalogDefinitionMatchesItem(item, def) {
|
||||
|
||||
@@ -1061,6 +1061,10 @@ 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,
|
||||
@@ -1146,7 +1150,7 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
@@ -1205,7 +1209,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.PresignURL(ctx, posterPath, "featured")
|
||||
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
@@ -1266,7 +1270,7 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
|
||||
item.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
item.PosterURL = s.PresignURL(ctx, poster, "featured")
|
||||
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
|
||||
entries = append(entries, item)
|
||||
}
|
||||
if len(entries) < 2 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -40,3 +42,37 @@ func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresignAudiobookPosterURLUsesPosterVariant(t *testing.T) {
|
||||
resolver := &recordingCatalogImageResolver{}
|
||||
detail := &DetailService{}
|
||||
detail.SetImageResolver(resolver)
|
||||
|
||||
got := detail.presignAudiobookPosterURL(context.Background(), "local/audiobooks/book/poster/original.webp")
|
||||
|
||||
if !strings.Contains(got, "/w500.webp") {
|
||||
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
|
||||
}
|
||||
if resolver.variant != "featured" {
|
||||
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCatalogImageResolver struct {
|
||||
path string
|
||||
variant string
|
||||
}
|
||||
|
||||
func (r *recordingCatalogImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
|
||||
r.path = path
|
||||
r.variant = variant
|
||||
return "resolved://" + path
|
||||
}
|
||||
|
||||
func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
|
||||
out := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
out[path] = r.ResolveImageURL(context.Background(), path, variant)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -35,6 +35,26 @@ 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
|
||||
@@ -45,60 +65,87 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
|
||||
// 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.
|
||||
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`
|
||||
var itemColumns = joinItemColumns("")
|
||||
|
||||
func qualifiedItemColumns(alias string) string {
|
||||
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 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
|
||||
}
|
||||
prefixed := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
prefixed[i] = alias + "." + col
|
||||
}
|
||||
return strings.Join(prefixed, ", ")
|
||||
return strings.Join(refs, ", ")
|
||||
}
|
||||
|
||||
func qualifiedListItemColumns(alias string) string {
|
||||
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 {
|
||||
exprs := make([]string, len(itemColumnNames))
|
||||
for i, col := range itemColumnNames {
|
||||
if col == "last_air_date" {
|
||||
prefixed[i] = effectiveLastAirDateExpr(alias)
|
||||
exprs[i] = effectiveLastAirDateExpr(alias) + " AS last_air_date"
|
||||
continue
|
||||
}
|
||||
prefixed[i] = alias + "." + col
|
||||
exprs[i] = itemColumnExpr(alias, col)
|
||||
}
|
||||
return strings.Join(prefixed, ", ")
|
||||
return strings.Join(exprs, ", ")
|
||||
}
|
||||
|
||||
// scanItem scans a single row into a *models.MediaItem.
|
||||
@@ -956,8 +1003,12 @@ 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 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.
|
||||
qualifiedCols := qualifiedItemColumns("mi")
|
||||
groupByCols := qualifiedItemColumnRefs("mi")
|
||||
scoredCTE := fmt.Sprintf(`
|
||||
WITH scored AS (
|
||||
SELECT
|
||||
@@ -984,7 +1035,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, qualifiedCols)
|
||||
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, groupByCols)
|
||||
|
||||
// COUNT(*) OVER () runs after the GROUP BY in the scored CTE collapses
|
||||
// duplicates from the library JOIN, so the window count preserves the
|
||||
|
||||
@@ -2,6 +2,7 @@ package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -367,3 +368,59 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMediaScopeItemTypes pins the expansion of group scopes: "video" covers
|
||||
// the video-side media_items types so search/browse can offer a
|
||||
// "Movies & Series vs Audiobooks" split without enumerating types per caller.
|
||||
func TestMediaScopeItemTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
scope string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"movie", []string{"movie"}},
|
||||
{"audiobook", []string{"audiobook"}},
|
||||
{"video", []string{"movie", "series"}},
|
||||
{" Video ", []string{"movie", "series"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MediaScopeItemTypes(tc.scope); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("MediaScopeItemTypes(%q) = %v, want %v", tc.scope, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaScopeMatchesItemType(t *testing.T) {
|
||||
cases := []struct {
|
||||
scope string
|
||||
itemType string
|
||||
want bool
|
||||
}{
|
||||
{"", "audiobook", true},
|
||||
{"video", "movie", true},
|
||||
{"video", "series", true},
|
||||
{"video", "audiobook", false},
|
||||
{"audiobook", "audiobook", true},
|
||||
{"movie", "series", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MediaScopeMatchesItemType(tc.scope, tc.itemType); got != tc.want {
|
||||
t.Errorf("MediaScopeMatchesItemType(%q, %q) = %v, want %v", tc.scope, tc.itemType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCatalogRequest_VideoMediaScope asserts ?type=video parses into the
|
||||
// query definition's media scope, and that catalogSearchAccess expands it to
|
||||
// the video item types for the direct search path.
|
||||
func TestParseCatalogRequest_VideoMediaScope(t *testing.T) {
|
||||
req, err := ParseCatalogRequest(url.Values{
|
||||
"source": {"query"},
|
||||
"q": {"the rookie"},
|
||||
"type": {"video"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCatalogRequest: %v", err)
|
||||
}
|
||||
if req.Query.MediaScope != "video" {
|
||||
t.Fatalf("expected media scope video, got %q", req.Query.MediaScope)
|
||||
}
|
||||
|
||||
_, itemTypes, earlyEmpty := catalogSearchAccess(req, AccessFilter{})
|
||||
if earlyEmpty {
|
||||
t.Fatal("unexpected early empty")
|
||||
}
|
||||
if !reflect.DeepEqual(itemTypes, []string{"movie", "series"}) {
|
||||
t.Fatalf("expected video scope to expand to movie+series, got %v", itemTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewPage_VideoScopeUsesTypeAny asserts the preview/query-executor
|
||||
// path renders a multi-type condition for the video group scope.
|
||||
func TestPreviewPage_VideoScopeUsesTypeAny(t *testing.T) {
|
||||
sql, args, err := (&QueryExecutor{}).buildPreviewPageSQL(
|
||||
QueryDefinition{
|
||||
MediaScope: "video",
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
},
|
||||
AccessFilter{},
|
||||
20,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPreviewPageSQL error: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, "mi.type = ANY(") {
|
||||
t.Fatalf("expected mi.type = ANY(...) for video scope, got %s", sql)
|
||||
}
|
||||
found := false
|
||||
for _, arg := range args {
|
||||
if types, ok := arg.([]string); ok && reflect.DeepEqual(types, []string{"movie", "series"}) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected movie+series type arg, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryDefinitionValidate_VideoScope asserts "video" passes definition
|
||||
// validation alongside the single-type scopes.
|
||||
func TestQueryDefinitionValidate_VideoScope(t *testing.T) {
|
||||
def := QueryDefinition{MediaScope: "video"}
|
||||
if err := def.Validate(); err != nil {
|
||||
t.Fatalf("expected video media scope to validate, got %v", err)
|
||||
}
|
||||
bad := QueryDefinition{MediaScope: "podcast"}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Fatal("expected invalid media scope to fail validation")
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,53 @@ 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
|
||||
@@ -182,12 +229,8 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
}
|
||||
}
|
||||
|
||||
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 !IsValidMediaScope(normalized.MediaScope) {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
|
||||
@@ -282,8 +282,14 @@ func (e *QueryExecutor) buildPreviewPagePlan(
|
||||
conditions = append(conditions, filterWhere)
|
||||
}
|
||||
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
|
||||
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
|
||||
args = append(args, 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)
|
||||
}
|
||||
argIdx++
|
||||
}
|
||||
libScopeWhere, libScopeArgs, hasLibraryScope := "", []any(nil), false
|
||||
|
||||
@@ -355,9 +355,9 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
}
|
||||
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
|
||||
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
|
||||
// listener off unless ABS compatibility is explicitly enabled.
|
||||
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -35,7 +35,15 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
|
||||
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"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ func ExtractToken(r *http.Request) (string, bool) {
|
||||
if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" {
|
||||
return token, true
|
||||
}
|
||||
if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" {
|
||||
// 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 != "" {
|
||||
return token, true
|
||||
}
|
||||
|
||||
@@ -185,7 +186,12 @@ 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.
|
||||
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
|
||||
//
|
||||
// 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 playSession, found := playbackStore.Get(playSessionID); found {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
|
||||
serveWithSession(next, w, r, session)
|
||||
|
||||
@@ -119,6 +119,60 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
sessions := NewSessionStore(30*24*time.Hour, clock)
|
||||
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
|
||||
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
|
||||
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
|
||||
|
||||
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
wantCode int
|
||||
}{
|
||||
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
|
||||
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
|
||||
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
|
||||
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
|
||||
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
|
||||
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
|
||||
{"no auth at all", "static=true", http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gotSession := false
|
||||
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if SessionFromContext(r.Context()) != nil {
|
||||
gotSession = true
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
|
||||
}
|
||||
if tc.wantCode == http.StatusOK && !gotSession {
|
||||
t.Fatal("expected authenticated session in context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
|
||||
for _, key := range []string{"api_key", "Api_Key", "API_KEY"} {
|
||||
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
|
||||
if got, ok := ExtractToken(req); !ok || got != "tok123" {
|
||||
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
|
||||
authn := newAdminAPIKeyAuthForTest(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
|
||||
@@ -226,6 +226,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
|
||||
Limit: fetchLimit,
|
||||
MaxLimit: compatBrowseMaxLimit,
|
||||
Offset: requestedOffset,
|
||||
RequireBackdrop: parseBool(params.Get("require_backdrop"), false),
|
||||
}
|
||||
|
||||
var collected []upstreamListItem
|
||||
|
||||
@@ -48,6 +48,7 @@ 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"`
|
||||
@@ -73,6 +74,7 @@ type baseItemDTO struct {
|
||||
SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"`
|
||||
MovieCount int `json:"MovieCount,omitempty"`
|
||||
SeriesCount int `json:"SeriesCount,omitempty"`
|
||||
SeasonCount int `json:"SeasonCount,omitempty"`
|
||||
EpisodeCount int `json:"EpisodeCount,omitempty"`
|
||||
LockedFields []string `json:"LockedFields,omitempty"`
|
||||
LockData bool `json:"LockData,omitempty"`
|
||||
|
||||
@@ -14,16 +14,18 @@ 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"`
|
||||
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"`
|
||||
PrimaryImageHeight int `json:"PrimaryImageHeight"`
|
||||
PrimaryImageWidth int `json:"PrimaryImageWidth"`
|
||||
Client string `json:"Client"`
|
||||
CustomPrefs map[string]string `json:"CustomPrefs"`
|
||||
}
|
||||
|
||||
// DisplayPreferencesHandler serves Jellyfin display preferences endpoints,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultDisplayPreferencesIncludesRequiredImageDimensions(t *testing.T) {
|
||||
dto := defaultDisplayPreferences("default", "Wholphin")
|
||||
|
||||
body, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal default display preferences: %v", err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
t.Fatalf("unmarshal default display preferences: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := raw["PrimaryImageHeight"]; !ok {
|
||||
t.Fatal("PrimaryImageHeight missing from display preferences JSON")
|
||||
}
|
||||
if _, ok := raw["PrimaryImageWidth"]; !ok {
|
||||
t.Fatal("PrimaryImageWidth missing from display preferences JSON")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,7 +17,6 @@ import (
|
||||
type ImagesHandler struct {
|
||||
content ContentService
|
||||
codec *ResourceIDCodec
|
||||
httpClient *http.Client
|
||||
sessions *SessionStore
|
||||
images *ImageCache
|
||||
personRepo *catalog.PersonRepository
|
||||
@@ -29,6 +29,7 @@ type ImagesHandler struct {
|
||||
posterSigner LibraryPosterPresigner
|
||||
presignTTL time.Duration
|
||||
imageTags *imageTagSigner
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type imageItemRepository interface {
|
||||
@@ -48,15 +49,14 @@ type imageFolderRepository interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
// NewImagesHandler creates an image proxy handler.
|
||||
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler {
|
||||
// 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 {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &ImagesHandler{
|
||||
content: content,
|
||||
codec: codec,
|
||||
httpClient: httpClient,
|
||||
sessions: sessions,
|
||||
images: images,
|
||||
personRepo: personRepo,
|
||||
@@ -69,6 +69,7 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient
|
||||
posterSigner: posterSigner,
|
||||
presignTTL: presignTTL,
|
||||
imageTags: newImageTagSigner(imageTagSecret),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +81,10 @@ 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 {
|
||||
@@ -88,15 +93,15 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
if ok {
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
|
||||
h.proxyImageURL(w, r, imageURL.URL)
|
||||
h.serveImageURL(w, r, imageURL.URL)
|
||||
return
|
||||
}
|
||||
if imageURL, ok := h.images.LookupTag(tag); ok {
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,7 +138,7 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
}
|
||||
|
||||
// handlePersonImage serves person photo images.
|
||||
@@ -159,7 +164,7 @@ func (h *ImagesHandler) handlePersonImage(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
|
||||
h.proxyImageURL(w, r, imageURL)
|
||||
h.serveImageURL(w, r, imageURL)
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) resolveItemImageURL(ctx context.Context, session *Session, contentID, imageType string, r *http.Request) (catalog.ResolvedImageURL, error) {
|
||||
@@ -414,18 +419,45 @@ 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) {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, imageURL, nil)
|
||||
target, err := parseRemoteImageURL(imageURL)
|
||||
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)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
}
|
||||
resp, err := h.httpClient.Do(req)
|
||||
copyConditionalImageRequestHeaders(req.Header, r.Header)
|
||||
|
||||
client := h.httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
@@ -433,6 +465,15 @@ func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, im
|
||||
proxyImage(w, resp)
|
||||
}
|
||||
|
||||
func parseRemoteImageURL(imageURL string) (*url.URL, error) {
|
||||
target, err := url.Parse(imageURL)
|
||||
if err != nil || target.Scheme == "" || target.Host == "" ||
|
||||
(target.Scheme != "http" && target.Scheme != "https") {
|
||||
return nil, errors.New("invalid remote image URL")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// HandleUserImage returns a deterministic placeholder avatar.
|
||||
func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
|
||||
@@ -140,6 +140,8 @@ 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=...
|
||||
@@ -150,6 +152,18 @@ 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())
|
||||
@@ -209,6 +223,7 @@ 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 != "" {
|
||||
@@ -919,9 +934,35 @@ 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)
|
||||
@@ -944,10 +985,16 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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: len(items),
|
||||
StartIndex: 0,
|
||||
TotalRecordCount: total,
|
||||
StartIndex: startIndex,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -19,7 +20,10 @@ 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) {
|
||||
@@ -50,8 +54,15 @@ func (s *countingContentService) SearchItems(context.Context, *Session, string,
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
|
||||
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) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
|
||||
@@ -70,6 +81,65 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
seriesContentID := "series-1"
|
||||
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
|
||||
contentSvc := &countingContentService{
|
||||
seasons: []upstreamSeason{
|
||||
{ContentID: "season-1", SeasonNumber: 1, Title: "Season 1", EpisodeCount: 10},
|
||||
{ContentID: "season-2", SeasonNumber: 2, Title: "Season 2", EpisodeCount: 8},
|
||||
},
|
||||
}
|
||||
|
||||
h := &ItemsHandler{
|
||||
content: contentSvc,
|
||||
userData: &mockUserDataService{},
|
||||
codec: codec,
|
||||
mapper: newMapper(codec, &config.Config{}),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/Users/test/Items?ParentId="+encodedSeriesID+
|
||||
"&IncludeItemTypes=Season&Recursive=false&SortBy=IndexNumber&SortOrder=Ascending"+
|
||||
"&Fields=PrimaryImageAspectRatio,CanDelete&StartIndex=1&Limit=1", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
|
||||
StreamAppUserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleItems(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if contentSvc.listSeasonsCalls != 1 || contentSvc.listSeasonsSeries != seriesContentID {
|
||||
t.Fatalf("ListSeasons calls = %d for %q, want 1 for %q",
|
||||
contentSvc.listSeasonsCalls, contentSvc.listSeasonsSeries, seriesContentID)
|
||||
}
|
||||
|
||||
var result queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if result.TotalRecordCount != 2 || result.StartIndex != 1 {
|
||||
t.Fatalf("TotalRecordCount/StartIndex = %d/%d, want 2/1",
|
||||
result.TotalRecordCount, result.StartIndex)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("len(Items) = %d, want 1", len(result.Items))
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.Type != "Season" || item.Name != "Season 2" {
|
||||
t.Fatalf("item = {%q %q}, want Season/Season 2", item.Type, item.Name)
|
||||
}
|
||||
if item.ParentID != encodedSeriesID || item.SeriesID != encodedSeriesID {
|
||||
t.Fatalf("ParentID/SeriesID = %q/%q, want %q/%q",
|
||||
item.ParentID, item.SeriesID, encodedSeriesID, encodedSeriesID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
|
||||
// that episode detail responses fetch parent series image metadata even when
|
||||
// image URLs are already cached. Cached URLs are not enough to build stable
|
||||
|
||||
@@ -439,7 +439,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
||||
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source)
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
EncodedIDGenre EncodedIDType = 6
|
||||
EncodedIDStudio EncodedIDType = 7
|
||||
EncodedIDPerson EncodedIDType = 8
|
||||
EncodedIDImageProxy EncodedIDType = 9
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -141,6 +142,21 @@ 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)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package jellycompat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMediaSourceIDsEqual(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "identical dashed",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "03000000-0000-0000-0000-00000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dashed vs compact (Wholphin)",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "0300000000000000000000000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compact vs dashed",
|
||||
a: "0300000000000000000000000019e8c2",
|
||||
b: "03000000-0000-0000-0000-00000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive hex",
|
||||
a: "03000000-0000-0000-0000-00000019E8C2",
|
||||
b: "0300000000000000000000000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different uuids",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "0300000000000000000000000019e8c3",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-uuid falls back to exact match",
|
||||
a: "abc",
|
||||
b: "abc",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-uuid mismatch",
|
||||
a: "abc",
|
||||
b: "def",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mediaSourceIDsEqual(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("mediaSourceIDsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
|
||||
return "", false
|
||||
}
|
||||
|
||||
if tag = strings.TrimSpace(tag); tag != "" {
|
||||
if tag = canonicalCompatImageTag(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(strings.TrimSpace(tag))
|
||||
return c.lookupTag(canonicalCompatImageTag(tag))
|
||||
}
|
||||
|
||||
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompatImageProxyTagVariantMiddlewareSuffixesInfuseImageTags(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: []baseItemDTO{{
|
||||
ID: "item-1",
|
||||
Name: "Movie",
|
||||
ImageTags: map[string]string{"Primary": "primary-tag"},
|
||||
BackdropImageTags: []string{"backdrop-tag"},
|
||||
SeriesPrimaryImageTag: "series-tag",
|
||||
ParentBackdropImageTags: []string{"parent-backdrop-tag"},
|
||||
ParentThumbImageTag: "parent-thumb-tag",
|
||||
ImageBlurHashes: map[string]map[string]string{
|
||||
"Primary": {"primary-tag": "thumbhash"},
|
||||
},
|
||||
}},
|
||||
TotalRecordCount: 1,
|
||||
})
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
|
||||
req.Header.Set("User-Agent", "Infuse-Direct/8.4.6")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
item := got.Items[0]
|
||||
if item.ImageTags["Primary"] != "primary-tag-p" {
|
||||
t.Fatalf("primary tag = %q, want primary-tag-p", item.ImageTags["Primary"])
|
||||
}
|
||||
if item.PrimaryImageItemID != compatImageProxyRouteID(codec, "item-1") {
|
||||
t.Fatalf("PrimaryImageItemId = %q, want proxy route id", item.PrimaryImageItemID)
|
||||
}
|
||||
if canonical, ok := canonicalCompatImageRouteID(codec, item.PrimaryImageItemID); !ok || canonical != "item-1" {
|
||||
t.Fatalf("canonical proxy route = %q, %v; want item-1, true", canonical, ok)
|
||||
}
|
||||
if item.BackdropImageTags[0] != "backdrop-tag-p" {
|
||||
t.Fatalf("backdrop tag = %q, want backdrop-tag-p", item.BackdropImageTags[0])
|
||||
}
|
||||
if item.SeriesPrimaryImageTag != "series-tag-p" {
|
||||
t.Fatalf("series tag = %q, want series-tag-p", item.SeriesPrimaryImageTag)
|
||||
}
|
||||
if item.ParentBackdropImageTags[0] != "parent-backdrop-tag-p" {
|
||||
t.Fatalf("parent backdrop tag = %q, want parent-backdrop-tag-p", item.ParentBackdropImageTags[0])
|
||||
}
|
||||
if item.ParentThumbImageTag != "parent-thumb-tag-p" {
|
||||
t.Fatalf("parent thumb tag = %q, want parent-thumb-tag-p", item.ParentThumbImageTag)
|
||||
}
|
||||
if _, ok := item.ImageBlurHashes["Primary"]["primary-tag-p"]; !ok {
|
||||
t.Fatalf("blurhash keys = %#v, want suffixed primary tag", item.ImageBlurHashes["Primary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatImageProxyTagVariantMiddlewareLeavesOtherClientsUnchanged(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: []baseItemDTO{{
|
||||
ID: "item-1",
|
||||
Name: "Movie",
|
||||
ImageTags: map[string]string{"Primary": "primary-tag"},
|
||||
}},
|
||||
TotalRecordCount: 1,
|
||||
})
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Items[0].ImageTags["Primary"] != "primary-tag" {
|
||||
t.Fatalf("primary tag = %q, want primary-tag", got.Items[0].ImageTags["Primary"])
|
||||
}
|
||||
if got.Items[0].PrimaryImageItemID != "" {
|
||||
t.Fatalf("PrimaryImageItemId = %q, want empty", got.Items[0].PrimaryImageItemID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatImageProxyRouteIDCanonicalizesNumericRouteWithoutRegistration(t *testing.T) {
|
||||
routeID := EncodeNumericID(EncodedIDItem, 12345).String()
|
||||
proxyRouteID := compatImageProxyRouteID(NewResourceIDCodec(), routeID)
|
||||
|
||||
canonical, ok := canonicalCompatImageRouteID(NewResourceIDCodec(), proxyRouteID)
|
||||
if !ok || canonical != routeID {
|
||||
t.Fatalf("canonical proxy route = %q, %v; want %q, true", canonical, ok, routeID)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
actual = strings.TrimSpace(actual)
|
||||
actual = canonicalCompatImageTag(actual)
|
||||
expected := s.Tag(seed, fallbackURL)
|
||||
if expected == "" || actual == "" || len(expected) != len(actual) {
|
||||
return false
|
||||
|
||||
+196
-18
@@ -2,48 +2,226 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const defaultImageProxyCacheControl = "public, max-age=300, must-revalidate"
|
||||
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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 < 200 || resp.StatusCode >= 300 {
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
setCompatImageRouteNoStore(w.Header())
|
||||
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
copyImageProxyHeaders(w.Header(), resp.Header)
|
||||
setCompatImageRouteNoStore(w.Header())
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func copyImageProxyHeaders(dst, src http.Header) {
|
||||
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)
|
||||
for _, key := range compatImageProxyHeaders {
|
||||
if value := src.Values(key); len(value) > 0 {
|
||||
dst[key] = append([]string(nil), value...)
|
||||
}
|
||||
}
|
||||
if dst.Get("Cache-Control") == "" {
|
||||
dst.Set("Cache-Control", defaultImageProxyCacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func placeholderAvatarPNG(seed string) ([]byte, error) {
|
||||
|
||||
+192
-114
@@ -2,10 +2,8 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,88 +14,66 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
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")),
|
||||
}
|
||||
|
||||
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 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) {
|
||||
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
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)
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
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")
|
||||
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")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.proxyImageURL(rec, req, upstream.URL)
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Fatalf("status = %d, want 304", rec.Code)
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
}
|
||||
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
|
||||
t.Fatal("signed-tag image URL was not cached after resolution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
|
||||
func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
var gotIfNoneMatch string
|
||||
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()
|
||||
@@ -124,13 +100,15 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(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="+tag, nil)
|
||||
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 = withImageRouteParams(req, routeID, "Primary")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -140,10 +118,90 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(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)
|
||||
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)
|
||||
}
|
||||
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
|
||||
t.Fatal("signed-tag image URL was not cached after resolution")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,10 +226,9 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: http.DefaultClient,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(""),
|
||||
codec: codec,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner(""),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
|
||||
@@ -186,9 +243,10 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("backdrop-bytes"))
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -201,9 +259,8 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t
|
||||
upstream.URL,
|
||||
)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
codec: codec,
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
BackdropPath: upstream.URL,
|
||||
@@ -217,18 +274,17 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "backdrop-bytes" {
|
||||
t.Fatalf("body = %q, want backdrop bytes", got)
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("library-poster"))
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -243,7 +299,6 @@ 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},
|
||||
@@ -256,18 +311,17 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "library-poster" {
|
||||
t.Fatalf("body = %q, want library poster", got)
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-image"))
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -276,10 +330,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing
|
||||
cache := NewImageCache(time.Hour, time.Now)
|
||||
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: cache,
|
||||
imageTags: newImageTagSigner("image-secret"),
|
||||
codec: codec,
|
||||
images: cache,
|
||||
imageTags: newImageTagSigner("image-secret"),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil)
|
||||
@@ -288,11 +341,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing
|
||||
|
||||
h.HandleItemImage(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "cached-image" {
|
||||
t.Fatalf("body = %q, want cached image", got)
|
||||
assertImageRedirect(t, rec, upstream.URL)
|
||||
if upstreamCalled {
|
||||
t.Fatal("compat image route proxied the upstream image instead of redirecting")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,11 +379,10 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}, false, nil, nil).ImageTags["Primary"]
|
||||
h := &ImagesHandler{
|
||||
codec: codec,
|
||||
httpClient: upstream.Client(),
|
||||
images: cache,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner("new-secret"),
|
||||
codec: codec,
|
||||
images: cache,
|
||||
itemRepo: fakeImageItemRepo{item: item},
|
||||
imageTags: newImageTagSigner("new-secret"),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
|
||||
@@ -349,6 +399,34 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectImageURLRejectsNonHTTPURL(t *testing.T) {
|
||||
h := &ImagesHandler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.redirectImageURL(rec, req, "catalog/poster.jpg")
|
||||
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, body = %s; want 502", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "" {
|
||||
t.Fatalf("Location = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertImageRedirect(t *testing.T, rec *httptest.ResponseRecorder, wantLocation string) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, body = %s; want 302", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != wantLocation {
|
||||
t.Fatalf("Location = %q, want %q", got, wantLocation)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
|
||||
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeImageItemRepo struct {
|
||||
item *models.MediaItem
|
||||
}
|
||||
|
||||
@@ -106,8 +106,10 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
|
||||
dto.RecursiveItemCount = *item.EpisodeCount
|
||||
}
|
||||
if item.SeasonCount != nil {
|
||||
dto.ChildCount = *item.SeasonCount
|
||||
dto.RecursiveItemCount = *item.SeasonCount
|
||||
seasonCount := *item.SeasonCount
|
||||
dto.ChildCount = seasonCount
|
||||
dto.RecursiveItemCount = seasonCount
|
||||
dto.SeasonCount = seasonCount
|
||||
}
|
||||
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
|
||||
if tags := imageTagsWithSeed(m.imageTagSigner,
|
||||
|
||||
@@ -35,3 +35,39 @@ func TestItemListSortNamePrefersSortTitle(t *testing.T) {
|
||||
t.Fatalf("SortName = %q, want %q", dto.SortName, "Matrix, The")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesListIncludesSeasonCount(t *testing.T) {
|
||||
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
||||
seasonCount := 4
|
||||
dto := m.itemFromList(upstreamListItem{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Snowpiercer",
|
||||
SeasonCount: &seasonCount,
|
||||
}, false, nil, nil)
|
||||
|
||||
if dto.SeasonCount != 4 {
|
||||
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
|
||||
}
|
||||
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
|
||||
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesDetailIncludesSeasonCount(t *testing.T) {
|
||||
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
||||
seasonCount := 4
|
||||
dto := m.itemFromDetail(upstreamItemDetail{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Snowpiercer",
|
||||
SeasonCount: &seasonCount,
|
||||
}, false, nil)
|
||||
|
||||
if dto.SeasonCount != 4 {
|
||||
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
|
||||
}
|
||||
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
|
||||
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba
|
||||
return &cp, nil, true
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if source.ID == routeID {
|
||||
if mediaSourceIDsEqual(source.ID, routeID) {
|
||||
cp := session
|
||||
sourceCopy := source
|
||||
return &cp, &sourceCopy, true
|
||||
|
||||
@@ -19,6 +19,7 @@ type itemsQuery struct {
|
||||
namePrefix string
|
||||
maxOfficialRating string
|
||||
parentLibraryID int
|
||||
parentItemID string
|
||||
specificIDs []string
|
||||
itemTypes []string
|
||||
genreName string
|
||||
@@ -32,6 +33,7 @@ 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
|
||||
@@ -55,6 +57,8 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +115,17 @@ 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, "")) != ""
|
||||
@@ -211,6 +226,9 @@ func buildBrowseParams(query itemsQuery) url.Values {
|
||||
params.Set("is_played", "false")
|
||||
}
|
||||
}
|
||||
if query.requireBackdrop {
|
||||
params.Set("require_backdrop", "true")
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseItemsQuery_ImageTypesBackdropFilter(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
wantRequire bool
|
||||
}{
|
||||
{"backdrop requested", "ImageTypes=Backdrop", true},
|
||||
{"lowercase param and value", "imagetypes=backdrop", true},
|
||||
{"backdrop among several", "ImageTypes=Primary,Backdrop,Logo", true},
|
||||
{"bracket array variant", "ImageTypes[]=Backdrop", true},
|
||||
{"primary only does not filter", "ImageTypes=Primary", false},
|
||||
{"absent does not filter", "Limit=1", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items?"+tc.rawQuery, nil)
|
||||
query := parseItemsQuery(req, codec)
|
||||
if query.requireBackdrop != tc.wantRequire {
|
||||
t.Fatalf("requireBackdrop = %v, want %v", query.requireBackdrop, tc.wantRequire)
|
||||
}
|
||||
params := buildBrowseParams(query)
|
||||
gotParam := params.Get("require_backdrop") == "true"
|
||||
if gotParam != tc.wantRequire {
|
||||
t.Fatalf("require_backdrop param = %q, want emitted=%v", params.Get("require_backdrop"), tc.wantRequire)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ 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)
|
||||
|
||||
@@ -99,7 +100,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.S3Client = deps.S3Client
|
||||
playbackHandler.S3Bucket = deps.S3Bucket
|
||||
}
|
||||
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret)
|
||||
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)
|
||||
displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider)
|
||||
recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn)
|
||||
|
||||
@@ -261,15 +262,15 @@ func withDefaults(deps Dependencies) Dependencies {
|
||||
if deps.Config != nil && deps.Config.JellyfinCompat.PlaybackSessionTTL > 0 {
|
||||
playbackTTL = deps.Config.JellyfinCompat.PlaybackSessionTTL
|
||||
}
|
||||
if deps.HTTPClient == nil {
|
||||
deps.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
if deps.DeviceProfiles == nil {
|
||||
deps.DeviceProfiles = NewDeviceProfileStore(playbackTTL, deps.Now)
|
||||
}
|
||||
if deps.PlaybackStore == nil {
|
||||
deps.PlaybackStore = NewPlaybackSessionStore(playbackTTL, deps.Now)
|
||||
}
|
||||
if deps.HTTPClient == nil {
|
||||
deps.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// Build ContentService from repos if not provided
|
||||
if deps.ContentService == nil && deps.BrowseRepo != nil && deps.ItemRepo != nil && deps.DetailSvc != nil {
|
||||
|
||||
@@ -27,7 +27,6 @@ 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
|
||||
@@ -38,6 +37,7 @@ type Dependencies struct {
|
||||
LoginResolver loginResolver
|
||||
Authenticator *Authenticator
|
||||
WebFS fs.FS
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Direct service dependencies (replaces Client)
|
||||
ContentService ContentService
|
||||
@@ -145,6 +145,5 @@ func NewDependencies(cfg *config.Config) Dependencies {
|
||||
Config: cfg,
|
||||
Now: time.Now,
|
||||
TokenGenerator: uuid.NewString,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID"))
|
||||
playSessionID := newCaseInsensitiveQuery(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 != "" && source.ID != mediaSourceID {
|
||||
if mediaSourceID != "" && !mediaSourceIDsEqual(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 current.MediaSources[index].ID == mediaSourceID {
|
||||
if mediaSourceIDsEqual(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 := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
|
||||
if playSessionID := newCaseInsensitiveQuery(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 source.ID == mediaSourceID {
|
||||
if mediaSourceIDsEqual(source.ID, mediaSourceID) {
|
||||
copy := source
|
||||
return ©
|
||||
}
|
||||
|
||||
+66
-21
@@ -303,9 +303,21 @@ func resolveEnabledProvidersByPriority(
|
||||
priority int
|
||||
}
|
||||
|
||||
items := make([]ranked, len(caps))
|
||||
for i, c := range caps {
|
||||
items[i] = ranked{cap: c, priority: LookupDefaultPriority(ctx, pool, c.PluginInstallationID, contentLevel)}
|
||||
// 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)})
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
@@ -326,32 +338,68 @@ func resolveEnabledProvidersByPriority(
|
||||
return buildProviders(ctx, sorted, resolver, pool), nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
var metadataJSON []byte
|
||||
err := pool.QueryRow(ctx,
|
||||
`SELECT metadata FROM plugin_capabilities
|
||||
WHERE plugin_installation_id = $1 AND capability_type = 'metadata_provider.v1'
|
||||
LIMIT 1`,
|
||||
WHERE plugin_installation_id = $1
|
||||
AND capability_id = $2
|
||||
AND capability_type = 'metadata_provider.v1'`,
|
||||
pluginInstallationID,
|
||||
capabilityID,
|
||||
).Scan(&metadataJSON)
|
||||
if err != nil {
|
||||
return 0
|
||||
return nil
|
||||
}
|
||||
return metadataJSON
|
||||
}
|
||||
|
||||
return extractDefaultPriority(metadataJSON, contentLevel)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 0
|
||||
return nil, false
|
||||
}
|
||||
// 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 {
|
||||
@@ -360,18 +408,15 @@ func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
|
||||
dpRaw, ok = inner["default_priority"]
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var dpMap map[string]float64
|
||||
if err := json.Unmarshal(dpRaw, &dpMap); err != nil {
|
||||
return 0
|
||||
return nil, false
|
||||
}
|
||||
if v, ok := dpMap[contentLevel]; ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
return dpMap, len(dpMap) > 0
|
||||
}
|
||||
|
||||
// ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package metadata
|
||||
|
||||
import "testing"
|
||||
|
||||
// Capability metadata as stored in plugin_capabilities.metadata: plugin-declared
|
||||
// fields are wrapped in a "metadata" envelope.
|
||||
const (
|
||||
audiobookCapMetadata = `{"metadata":{"default_priority":{"audiobook":2}},"display_name":"Audiobook Metadata"}`
|
||||
tmdbCapMetadata = `{"metadata":{"default_priority":{"movie":2,"season":3,"series":3,"episode":3}},"display_name":"TMDB"}`
|
||||
tvdbCapMetadata = `{"metadata":{"default_priority":{"season":2,"series":2,"episode":2}},"display_name":"TVDB"}`
|
||||
)
|
||||
|
||||
func TestProviderSupportsLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metadataJSON string
|
||||
contentLevel string
|
||||
want bool
|
||||
}{
|
||||
// An audiobook-only provider must NOT be pulled into video content
|
||||
// levels by the chain-less global fallback. This is the regression
|
||||
// that let silo.audiobook-metadata hammer audiobook APIs with
|
||||
// anime/movie/series titles.
|
||||
{"audiobook provider excluded from episode", audiobookCapMetadata, "episode", false},
|
||||
{"audiobook provider excluded from series", audiobookCapMetadata, "series", false},
|
||||
{"audiobook provider excluded from season", audiobookCapMetadata, "season", false},
|
||||
{"audiobook provider excluded from movie", audiobookCapMetadata, "movie", false},
|
||||
{"audiobook provider included for audiobook", audiobookCapMetadata, "audiobook", true},
|
||||
|
||||
// Providers that declare a level stay eligible for it.
|
||||
{"tmdb included for movie", tmdbCapMetadata, "movie", true},
|
||||
{"tmdb included for season", tmdbCapMetadata, "season", true},
|
||||
{"tmdb included for episode", tmdbCapMetadata, "episode", true},
|
||||
|
||||
// tvdb declares no movie level -> excluded from the movie fallback.
|
||||
{"tvdb excluded from movie", tvdbCapMetadata, "movie", false},
|
||||
{"tvdb included for series", tvdbCapMetadata, "series", true},
|
||||
|
||||
// A provider that declares no default_priority makes no claim and stays
|
||||
// eligible everywhere (legacy behavior), ranked last by priority 0.
|
||||
{"no metadata is eligible", ``, "episode", true},
|
||||
{"empty object is eligible", `{}`, "episode", true},
|
||||
{"empty priority map is eligible", `{"metadata":{"default_priority":{}}}`, "episode", true},
|
||||
{"malformed json is eligible (fail-open)", `{not json`, "episode", true},
|
||||
|
||||
// A declared level with non-positive priority is not supported.
|
||||
{"zero priority level not supported", `{"metadata":{"default_priority":{"episode":0}}}`, "episode", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := providerSupportsLevel([]byte(tc.metadataJSON), tc.contentLevel)
|
||||
if got != tc.want {
|
||||
t.Fatalf("providerSupportsLevel(%q, %q) = %v, want %v",
|
||||
tc.metadataJSON, tc.contentLevel, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,32 @@ 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
|
||||
@@ -477,10 +501,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 = NOW() + $3::interval,
|
||||
available_at = `+matchQueueBackoffExpr("$3", "$4")+`,
|
||||
updated_at = NOW()
|
||||
WHERE media_file_id = $1
|
||||
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay)); err != nil {
|
||||
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay)); 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 = NOW() + $4::interval,
|
||||
available_at = `+matchQueueBackoffExpr("$4", "$5")+`,
|
||||
updated_at = NOW()
|
||||
WHERE media_folder_id = $1 AND observed_root_path = $2
|
||||
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay))
|
||||
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay))
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating series root queue error: %w", err)
|
||||
}
|
||||
|
||||
@@ -3856,10 +3856,6 @@ 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
|
||||
@@ -3874,9 +3870,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), folderType)
|
||||
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath))
|
||||
if folderIDs == nil && contentRootPath != "" && contentRootPath != observedRootPath {
|
||||
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath), folderType)
|
||||
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath))
|
||||
}
|
||||
|
||||
effectiveExternalIDs := folderIDs
|
||||
|
||||
+20
-46
@@ -3,7 +3,6 @@ package naming
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205},
|
||||
@@ -11,7 +10,6 @@ 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
|
||||
@@ -48,54 +46,30 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints {
|
||||
return hints
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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 {
|
||||
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
|
||||
if hints == nil {
|
||||
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
|
||||
}
|
||||
if hints.ImdbID == "" {
|
||||
hints.ImdbID = strings.ToLower(m[1])
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeYear(value string) bool {
|
||||
return len(value) == 4 && value >= "1800" && value <= "2100"
|
||||
return hints
|
||||
}
|
||||
|
||||
@@ -2,36 +2,63 @@ package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
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)
|
||||
}
|
||||
if got := ParseFolderIDs("22 7", "series"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got)
|
||||
}
|
||||
|
||||
// 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 81189","series") = %+v, want TvdbID="81189"`, 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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_StructuredTagsStillParse(t *testing.T) {
|
||||
got := ParseFolderIDs("{tmdb-27205}")
|
||||
if got == nil || got.TmdbID != "27205" {
|
||||
t.Errorf(`ParseFolderIDs("{tmdb-27205}") = %+v, want TmdbID="27205"`, got)
|
||||
}
|
||||
|
||||
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189]")
|
||||
if got == nil || got.TvdbID != "81189" {
|
||||
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189]") = %+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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "movie")
|
||||
got := ParseFolderIDs(c.folder)
|
||||
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]", "movie"); got == nil || got.ImdbID != "tt1375666" {
|
||||
if got := ParseFolderIDs("Movie [imdb-tt1375666]"); got == nil || got.ImdbID != "tt1375666" {
|
||||
t.Errorf("structured imdb regressed: %+v", got)
|
||||
}
|
||||
if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil {
|
||||
if got := ParseFolderIDs("Some Movie (2020) [BB]"); got != nil {
|
||||
t.Errorf("non-tt bracket tag must not parse as id, got %+v", got)
|
||||
}
|
||||
if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil {
|
||||
if got := ParseFolderIDs("17 Blocks (2021)"); got != nil {
|
||||
t.Errorf("no-id folder must return nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,20 @@ 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, &group)
|
||||
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, idAnchored, &group)
|
||||
} else {
|
||||
populateMovieGroupIdentity(cleanFilePath, assignment, &group)
|
||||
populateMovieGroupIdentity(cleanFilePath, assignment, idAnchored, &group)
|
||||
}
|
||||
|
||||
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath), group.BaseType); ids != nil {
|
||||
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath)); ids != nil {
|
||||
group.TmdbID = ids.TmdbID
|
||||
group.ImdbID = ids.ImdbID
|
||||
group.TvdbID = ids.TvdbID
|
||||
@@ -79,7 +86,7 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
|
||||
return group
|
||||
}
|
||||
|
||||
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, group *GroupIdentity) {
|
||||
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
|
||||
parentDir := filepath.Dir(filePath)
|
||||
parentTitle, parentYear, parentTrusted := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
|
||||
parentTitle = StripComparisonSafeEditionSuffix(parentTitle)
|
||||
@@ -107,8 +114,12 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
|
||||
case parentTrusted && relation == titleRelationSoft:
|
||||
reasons = append(reasons, "variant_suffix")
|
||||
case parentTrusted && relation == titleRelationHard:
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "unrelated_title")
|
||||
if idAnchored {
|
||||
reasons = append(reasons, "unrelated_title_resolved_by_provider_ids")
|
||||
} else {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "unrelated_title")
|
||||
}
|
||||
default:
|
||||
if baseTitle == "" {
|
||||
baseTitle = StripComparisonSafeEditionSuffix(stem.Title)
|
||||
@@ -130,7 +141,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
|
||||
baseYear = parentYear
|
||||
}
|
||||
|
||||
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted {
|
||||
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted && !idAnchored {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "episode_pattern")
|
||||
}
|
||||
@@ -154,7 +165,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
|
||||
})
|
||||
}
|
||||
|
||||
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, group *GroupIdentity) {
|
||||
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
|
||||
ctx := ResolvePathContext(filePath, libraryType)
|
||||
title := assignment.Title
|
||||
year := assignment.Year
|
||||
@@ -179,8 +190,12 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
|
||||
case titleRelationSoft:
|
||||
reasons = append(reasons, "series_alias")
|
||||
case titleRelationHard:
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_title_conflict")
|
||||
if idAnchored {
|
||||
reasons = append(reasons, "series_title_conflict_resolved_by_provider_ids")
|
||||
} else {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_title_conflict")
|
||||
}
|
||||
}
|
||||
if year == 0 {
|
||||
year = observedYear
|
||||
@@ -191,8 +206,12 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
|
||||
case titleRelationSoft:
|
||||
reasons = append(reasons, "series_assignment_soft_conflict")
|
||||
case titleRelationHard:
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_assignment_conflict")
|
||||
if idAnchored {
|
||||
reasons = append(reasons, "series_assignment_conflict_resolved_by_provider_ids")
|
||||
} else {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_assignment_conflict")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +225,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 {
|
||||
if title == "" && assignment.HasEpisodePattern && !idAnchored {
|
||||
state = "ambiguous"
|
||||
reasons = append(reasons, "series_identity_missing")
|
||||
}
|
||||
@@ -229,6 +248,19 @@ 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)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
// A renamed release inside a provider-tagged folder must stay resolved: the
|
||||
// structured IDs anchor the identity even though folder and file titles are
|
||||
// unrelated (Radarr keeps the folder name from grab time while the release
|
||||
// inside carries the retitled name).
|
||||
func TestInferGroupIdentity_StructuredIDsResolveMovieTitleConflict(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
|
||||
"movies",
|
||||
RootAssignment{
|
||||
RootPath: "/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}",
|
||||
InferredType: "movie",
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "resolved"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.BaseTitle, "On Fire"; got != want {
|
||||
t.Fatalf("BaseTitle = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.TmdbID, "1196791"; got != want {
|
||||
t.Fatalf("TmdbID = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.ImdbID, "tt28078628"; got != want {
|
||||
t.Fatalf("ImdbID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An unbracketed trailing IMDb id anchors identity the same way a structured
|
||||
// tag does — the tt prefix is unambiguous. Mirrors the dev-library case
|
||||
// "Eggs Run (2021) tt8049994" whose file carries a different release title.
|
||||
func TestInferGroupIdentity_TrailingImdbIDResolvesMovieTitleConflict(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/movies-int/es/Eggs Run (2021) tt8049994/Huevitos Congelados (2022) [WEBDL-1080p]-GRP.mkv",
|
||||
"movies",
|
||||
RootAssignment{
|
||||
RootPath: "/movies-int/es/Eggs Run (2021) tt8049994",
|
||||
InferredType: "movie",
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "resolved"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.ImdbID, "tt8049994"; got != want {
|
||||
t.Fatalf("ImdbID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Without provider IDs the same folder/file title conflict must still be
|
||||
// flagged ambiguous — there is nothing to anchor the identity.
|
||||
func TestInferGroupIdentity_TitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/movies/20s/On Fire (2024)/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
|
||||
"movies",
|
||||
RootAssignment{
|
||||
RootPath: "/movies/20s/On Fire (2024)",
|
||||
InferredType: "movie",
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "ambiguous"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferGroupIdentity_StructuredIDsResolveSeriesTitleConflict(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/tv/Border Control - Sweden (2023) {tvdb-442777}/Season 01/Gränsbevakarna Sverige S01E01.mkv",
|
||||
"series",
|
||||
RootAssignment{
|
||||
RootPath: "/tv/Border Control - Sweden (2023) {tvdb-442777}",
|
||||
InferredType: "series",
|
||||
Title: "Gränsbevakarna Sverige",
|
||||
HasSeasonStructure: true,
|
||||
HasEpisodePattern: true,
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "resolved"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := group.TvdbID, "442777"; got != want {
|
||||
t.Fatalf("TvdbID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferGroupIdentity_SeriesTitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
|
||||
group := InferGroupIdentity(
|
||||
"/tv/Border Control - Sweden (2023)/Season 01/Gränsbevakarna Sverige S01E01.mkv",
|
||||
"series",
|
||||
RootAssignment{
|
||||
RootPath: "/tv/Border Control - Sweden (2023)",
|
||||
InferredType: "series",
|
||||
Title: "Gränsbevakarna Sverige",
|
||||
HasSeasonStructure: true,
|
||||
HasEpisodePattern: true,
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := group.State, "ambiguous"; got != want {
|
||||
t.Fatalf("State = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func parseInferFolderTitleYear(name string) (string, int, bool) {
|
||||
year, _ := strconv.Atoi(match[2])
|
||||
return strings.TrimSpace(match[1]), year, true
|
||||
}
|
||||
if ids := ParseFolderIDs(name, "movie"); ids != nil || ParseFolderIDs(name, "series") != nil {
|
||||
if ParseFolderIDs(name) != nil {
|
||||
return strings.TrimSpace(surface), 0, true
|
||||
}
|
||||
return strings.TrimSpace(surface), 0, false
|
||||
|
||||
@@ -89,7 +89,7 @@ func InferRootAssignments(
|
||||
Year: assignment.Year,
|
||||
},
|
||||
}
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); 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), assignment.InferredType); ids != nil {
|
||||
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
|
||||
assignment.HasFolderIDs = true
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ func detectInferMovieFolderEvidence(parentBase string, nameNoExt string, hasSeas
|
||||
if hasSeasonStructure {
|
||||
return false
|
||||
}
|
||||
if ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil {
|
||||
if ParseFolderIDs(parentBase) != 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, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil
|
||||
parentIDs := ParseFolderIDs(parentBase) != nil
|
||||
childTitle, childYear := parseInferTitleYear(currentBase)
|
||||
parentTitle, parentYear := parseInferTitleYear(parentBase)
|
||||
|
||||
|
||||
@@ -31,14 +31,15 @@ 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
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
activeTransportCount int
|
||||
Position float64
|
||||
IsPaused bool
|
||||
HasWebSocket bool
|
||||
HasRealtimeConnection bool
|
||||
DisableProgressPersistence bool
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastActivityAt time.Time
|
||||
activeTransportCount int
|
||||
}
|
||||
|
||||
// SessionStreamState stores the mutable stream-specific details that can
|
||||
@@ -371,6 +372,23 @@ func (m *SessionManager) SetRealtimeConnection(sessionID string, connected bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProgressPersistenceDisabled controls whether session progress updates and
|
||||
// stop events should write resume/history state. This is useful for players
|
||||
// whose resume timeline is not the same as the session's file-local timeline.
|
||||
func (m *SessionManager) SetProgressPersistenceDisabled(sessionID string, disabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
s, ok := m.sessions[sessionID]
|
||||
if !ok {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
s.DisableProgressPersistence = disabled
|
||||
m.touchSessionLocked(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchActivity refreshes the session's activity timestamp without changing
|
||||
// any other playback state.
|
||||
func (m *SessionManager) TouchActivity(sessionID string) error {
|
||||
|
||||
@@ -54,6 +54,11 @@ type ScanSourceClient struct {
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type RequestRouterClient struct {
|
||||
client pluginv1.RequestRouterClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type EventConsumerClient struct {
|
||||
client pluginv1.EventConsumerClient
|
||||
timeout time.Duration
|
||||
@@ -148,6 +153,16 @@ 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
|
||||
@@ -283,6 +298,36 @@ func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollCh
|
||||
return c.client.PollChanges(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.Fulfill(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.CheckStatus(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ListConfigOptions(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.TestConnection(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *RequestRouterClient) Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.Validate(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *EventConsumerClient) HandleEvent(ctx context.Context, req *pluginv1.HandleEventRequest) (*pluginv1.HandleEventResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -23,6 +23,9 @@ const (
|
||||
DefaultEventTimeout = 10 * time.Second
|
||||
DefaultAuthTimeout = 10 * time.Second
|
||||
DefaultRouteTimeout = 10 * time.Second
|
||||
// DefaultRequestRouterTimeout bounds a single request_router RPC.
|
||||
// Fulfillment hits remote arr instances, so allow generous headroom.
|
||||
DefaultRequestRouterTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
func HandshakeConfig() plugin.HandshakeConfig {
|
||||
|
||||
@@ -10,9 +10,11 @@ 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"
|
||||
|
||||
@@ -27,6 +29,7 @@ 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)
|
||||
@@ -66,6 +69,7 @@ type Service struct {
|
||||
dispatcher *EventDispatcher
|
||||
lifecycleMu sync.RWMutex
|
||||
lifecycleHooks []func(context.Context)
|
||||
launchGroup singleflight.Group
|
||||
}
|
||||
|
||||
// SetEventDispatcher wires the EventDispatcher into the Service. The
|
||||
@@ -482,6 +486,18 @@ 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,
|
||||
@@ -605,7 +621,25 @@ func (s *Service) ManifestForInstallation(
|
||||
return s.manifestForInstallation(ctx, installationID, false)
|
||||
}
|
||||
|
||||
// ensureClient returns a running client for the installation, collapsing
|
||||
// concurrent first-use of a cold installation into a single launch so a burst of
|
||||
// callers does not spawn redundant plugin processes (Host.Start releases its lock
|
||||
// during the slow launch and cannot dedupe). After the flight completes the key
|
||||
// is freed, so subsequent callers re-run and hit the now-warm cache.
|
||||
func (s *Service) ensureClient(ctx context.Context, installationID int) (pluginClient, error) {
|
||||
v, err, _ := s.launchGroup.Do(strconv.Itoa(installationID), func() (any, error) {
|
||||
// Isolate the shared launch from the leader caller's cancellation: other
|
||||
// waiters depend on this in-flight launch, so a single caller's canceled
|
||||
// request must not tear it down. Values (tracing, auth) are preserved.
|
||||
return s.doEnsureClient(context.WithoutCancel(ctx), installationID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(pluginClient), nil
|
||||
}
|
||||
|
||||
func (s *Service) doEnsureClient(ctx context.Context, installationID int) (pluginClient, error) {
|
||||
installation, err := s.loadInstallation(ctx, installationID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -45,6 +45,16 @@ 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 {
|
||||
@@ -224,3 +234,66 @@ 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,6 +7,7 @@ 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 {
|
||||
@@ -149,6 +150,35 @@ func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginConnectionCheckSkipsMovieProbeForAudiobookOnlyProvider(t *testing.T) {
|
||||
metadata, err := structpb.NewStruct(map[string]any{
|
||||
"default_priority": map[string]any{
|
||||
"audiobook": 2,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStruct() error = %v", err)
|
||||
}
|
||||
|
||||
manifest := connectionTestManifest(t, "silo.audiobook-metadata", "0.1.2")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
{
|
||||
Type: "metadata_provider.v1",
|
||||
Id: "audiobook-metadata",
|
||||
DisplayName: "Audiobook Metadata",
|
||||
Metadata: metadata,
|
||||
},
|
||||
}
|
||||
client := &fakePluginClient{manifest: manifest}
|
||||
|
||||
if err := runPluginConnectionCheck(context.Background(), client, manifest); err != nil {
|
||||
t.Fatalf("runPluginConnectionCheck() error = %v", err)
|
||||
}
|
||||
if client.metadataProviderCalls != 0 {
|
||||
t.Fatalf("metadata provider calls = %d, want 0", client.metadataProviderCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTestGlobalConfigReturnsUnsupportedWithoutStartingPlugin(t *testing.T) {
|
||||
manifest := connectionTestManifest(t, "silo.simple", "0.0.1")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
|
||||
@@ -188,7 +188,8 @@ func (f *fakeServiceHost) Shutdown(context.Context) error {
|
||||
}
|
||||
|
||||
type fakePluginClient struct {
|
||||
manifest *pluginv1.PluginManifest
|
||||
manifest *pluginv1.PluginManifest
|
||||
metadataProviderCalls int
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
|
||||
@@ -196,6 +197,7 @@ func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) MetadataProvider(string) (*pluginhost.MetadataProviderClient, error) {
|
||||
f.metadataProviderCalls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -215,6 +217,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
)
|
||||
|
||||
// countingHerdHost simulates the real Host: Start records the launched client so
|
||||
// a later Client() hits the warm cache; it counts Start calls and can delay each
|
||||
// launch to force concurrent overlap.
|
||||
type countingHerdHost struct {
|
||||
mu sync.Mutex
|
||||
current map[int]pluginClient
|
||||
starts int
|
||||
startResult pluginClient
|
||||
startErr error
|
||||
startDelay time.Duration
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Client(id int) (pluginClient, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if c, ok := h.current[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, pluginhost.ErrClientNotFound
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Start(_ context.Context, req pluginhost.StartRequest) (pluginClient, error) {
|
||||
if h.startDelay > 0 {
|
||||
time.Sleep(h.startDelay)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.starts++
|
||||
if h.startErr != nil {
|
||||
return nil, h.startErr
|
||||
}
|
||||
if h.current == nil {
|
||||
h.current = map[int]pluginClient{}
|
||||
}
|
||||
h.current[req.InstallationID] = h.startResult
|
||||
return h.startResult, nil
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Stop(id int) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.current, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *countingHerdHost) Shutdown(context.Context) error { return nil }
|
||||
|
||||
func (h *countingHerdHost) startCount() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.starts
|
||||
}
|
||||
|
||||
func TestEnsureClientSingleFlightsConcurrentLaunch(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
store := newFakeServiceInstallationStore(&Installation{
|
||||
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
|
||||
InstallPath: installPath, Enabled: true,
|
||||
})
|
||||
host := &countingHerdHost{
|
||||
startResult: &fakePluginClient{manifest: manifest},
|
||||
startDelay: 25 * time.Millisecond,
|
||||
}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
release := make(chan struct{})
|
||||
clients := make([]pluginClient, n)
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-release
|
||||
clients[i], errs[i] = service.ensureClient(context.Background(), 7)
|
||||
}(i)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := host.startCount(); got != 1 {
|
||||
t.Fatalf("host.Start calls = %d, want 1 (singleflighted)", got)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("caller %d err = %v", i, errs[i])
|
||||
}
|
||||
if clients[i] != host.startResult {
|
||||
t.Fatalf("caller %d got %#v, want shared launched client", i, clients[i])
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := service.ensureClient(context.Background(), 7); err != nil {
|
||||
t.Fatalf("post-warm ensureClient err = %v", err)
|
||||
}
|
||||
if got := host.startCount(); got != 1 {
|
||||
t.Fatalf("post-warm host.Start calls = %d, want still 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClientDistinctInstallationsLaunchIndependently(t *testing.T) {
|
||||
mA := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
mB := testPluginManifest(t, "silo.other", "1.0.0")
|
||||
pathA := writeInstalledPluginManifest(t, mA)
|
||||
pathB := writeInstalledPluginManifest(t, mB)
|
||||
store := newFakeServiceInstallationStore(
|
||||
&Installation{ID: 7, PluginID: mA.GetPluginId(), Version: mA.GetVersion(), InstallPath: pathA, Enabled: true},
|
||||
&Installation{ID: 8, PluginID: mB.GetPluginId(), Version: mB.GetVersion(), InstallPath: pathB, Enabled: true},
|
||||
)
|
||||
host := &countingHerdHost{startResult: &fakePluginClient{manifest: mA}, startDelay: 10 * time.Millisecond}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range []int{7, 8, 7, 8} {
|
||||
wg.Add(1)
|
||||
go func(id int) { defer wg.Done(); _, _ = service.ensureClient(context.Background(), id) }(id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := host.startCount(); got != 2 {
|
||||
t.Fatalf("host.Start calls = %d, want 2 (one per installation)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ctxCaptureHost blocks inside Start until released, and records whether the
|
||||
// launch context was canceled. It lets a test cancel the singleflight leader's
|
||||
// ctx while a launch is in flight.
|
||||
type ctxCaptureHost struct {
|
||||
mu sync.Mutex
|
||||
current map[int]pluginClient
|
||||
startResult pluginClient
|
||||
entered chan struct{}
|
||||
proceed chan struct{}
|
||||
sawCancel bool
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Client(id int) (pluginClient, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if c, ok := h.current[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, pluginhost.ErrClientNotFound
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Start(ctx context.Context, req pluginhost.StartRequest) (pluginClient, error) {
|
||||
close(h.entered)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.mu.Lock()
|
||||
h.sawCancel = true
|
||||
h.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
case <-h.proceed:
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.current == nil {
|
||||
h.current = map[int]pluginClient{}
|
||||
}
|
||||
h.current[req.InstallationID] = h.startResult
|
||||
return h.startResult, nil
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Stop(id int) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.current, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ctxCaptureHost) Shutdown(context.Context) error { return nil }
|
||||
|
||||
func (h *ctxCaptureHost) canceledDuringLaunch() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sawCancel
|
||||
}
|
||||
|
||||
// TestEnsureClientLaunchIsolatedFromLeaderCancellation pins that a deduped
|
||||
// launch is not coupled to the cancellation of whichever caller happened to be
|
||||
// the singleflight leader: if that caller's request is canceled mid-launch, the
|
||||
// shared launch (which other waiters depend on) must continue.
|
||||
func TestEnsureClientLaunchIsolatedFromLeaderCancellation(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
store := newFakeServiceInstallationStore(&Installation{
|
||||
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
|
||||
InstallPath: installPath, Enabled: true,
|
||||
})
|
||||
host := &ctxCaptureHost{
|
||||
startResult: &fakePluginClient{manifest: manifest},
|
||||
entered: make(chan struct{}),
|
||||
proceed: make(chan struct{}),
|
||||
}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
type result struct {
|
||||
c pluginClient
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, err := service.ensureClient(ctx, 7)
|
||||
done <- result{c, err}
|
||||
}()
|
||||
|
||||
<-host.entered // launch is in flight inside Start
|
||||
cancel() // cancel the leader's ctx mid-launch
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
t.Fatalf("ensureClient returned early after leader cancel (err=%v); launch was coupled to leader ctx", r.err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// still launching — isolated from the leader's cancellation, as required
|
||||
}
|
||||
|
||||
close(host.proceed)
|
||||
r := <-done
|
||||
if r.err != nil {
|
||||
t.Fatalf("ensureClient err = %v, want nil", r.err)
|
||||
}
|
||||
if r.c != host.startResult {
|
||||
t.Fatalf("got %#v, want launched client", r.c)
|
||||
}
|
||||
if host.canceledDuringLaunch() {
|
||||
t.Fatalf("launch ctx was canceled by the leader; expected isolation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClientFailedLaunchPropagatesToAllCallers(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
store := newFakeServiceInstallationStore(&Installation{
|
||||
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
|
||||
InstallPath: installPath, Enabled: true,
|
||||
})
|
||||
host := &countingHerdHost{startErr: context.DeadlineExceeded, startDelay: 15 * time.Millisecond}
|
||||
service := &Service{installations: store, host: host}
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
release := make(chan struct{})
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) { defer wg.Done(); <-release; _, errs[i] = service.ensureClient(context.Background(), 7) }(i)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] == nil {
|
||||
t.Fatalf("caller %d err = nil, want the launch error", i)
|
||||
}
|
||||
}
|
||||
if got := host.startCount(); got != 1 {
|
||||
t.Fatalf("failed launch host.Start calls = %d, want 1 (still single-flighted)", got)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// UIs without reaching back into the browser's localStorage.
|
||||
type UserIdentity struct {
|
||||
Username string
|
||||
Email string
|
||||
ProfileName string
|
||||
ProfileIsPrimary bool
|
||||
}
|
||||
@@ -44,8 +45,8 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p
|
||||
}
|
||||
|
||||
err := l.pool.QueryRow(ctx,
|
||||
"SELECT username FROM users WHERE id = $1", userID,
|
||||
).Scan(&out.Username)
|
||||
"SELECT username, COALESCE(email, '') FROM users WHERE id = $1", userID,
|
||||
).Scan(&out.Username, &out.Email)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, err
|
||||
}
|
||||
@@ -65,3 +66,22 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RequesterIdentityFromLookup adapts a UserIdentityLookup into the requests
|
||||
// service's requester-identity resolver (email + username by user id).
|
||||
type requesterIdentityAdapter struct{ lookup UserIdentityLookup }
|
||||
|
||||
func RequesterIdentityFromLookup(l UserIdentityLookup) *requesterIdentityAdapter {
|
||||
return &requesterIdentityAdapter{lookup: l}
|
||||
}
|
||||
|
||||
func (a *requesterIdentityAdapter) ResolveRequester(ctx context.Context, userID int) (string, string, error) {
|
||||
if a == nil || a.lookup == nil || userID <= 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
id, err := a.lookup.LookupIdentity(ctx, userID, "")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return id.Email, id.Username, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ func NewMiddleware(perKey RateLimiter, global RateLimiter, store SettingsStore,
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveBackend reports which limiter backend this process is actually
|
||||
// running, as opposed to the configured backend, which only takes effect
|
||||
// after a restart.
|
||||
func (mw *Middleware) ActiveBackend() string {
|
||||
if mw.isMemory {
|
||||
return "memory"
|
||||
}
|
||||
return "redis"
|
||||
}
|
||||
|
||||
// Init loads config and seeds defaults. Call once at startup.
|
||||
func (mw *Middleware) Init(ctx context.Context) error {
|
||||
if err := SeedDefaults(ctx, mw.store); err != nil {
|
||||
|
||||
@@ -90,6 +90,37 @@ func TestApplyMediaTypeFloorAddsAvailableSupplementalType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMediaTypeFloorAddsAvailableAudiobooks(t *testing.T) {
|
||||
items := []ScoredItem{
|
||||
{MediaItemID: "m1", Score: 1.00},
|
||||
{MediaItemID: "m2", Score: 0.99},
|
||||
{MediaItemID: "m3", Score: 0.98},
|
||||
{MediaItemID: "m4", Score: 0.97},
|
||||
{MediaItemID: "m5", Score: 0.96},
|
||||
{MediaItemID: "m6", Score: 0.95},
|
||||
{MediaItemID: "m7", Score: 0.94},
|
||||
{MediaItemID: "m8", Score: 0.93},
|
||||
{MediaItemID: "m9", Score: 0.92},
|
||||
{MediaItemID: "m10", Score: 0.91},
|
||||
}
|
||||
candidates := append([]ScoredItem(nil), items...)
|
||||
candidates = append(candidates,
|
||||
ScoredItem{MediaItemID: "a1", Score: 0.90},
|
||||
ScoredItem{MediaItemID: "a2", Score: 0.89},
|
||||
)
|
||||
mediaTypes := map[string]string{
|
||||
"m1": "movie", "m2": "movie", "m3": "movie", "m4": "movie", "m5": "movie",
|
||||
"m6": "movie", "m7": "movie", "m8": "movie", "m9": "movie", "m10": "movie",
|
||||
"a1": "audiobook", "a2": "audiobook",
|
||||
}
|
||||
|
||||
mixed := applyMediaTypeFloor(items, candidates, mediaTypes)
|
||||
|
||||
if got := countMediaType(mixed, mediaTypes, "audiobook"); got != 2 {
|
||||
t.Fatalf("expected 2 audiobooks from supplemental candidates, got %d in %#v", got, mixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMediaTypeFloorNoopsWithoutSupplementalType(t *testing.T) {
|
||||
items := []ScoredItem{
|
||||
{MediaItemID: "s1", Score: 1.00},
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
const aggregateMediaTypeFloorDivisor = 5
|
||||
|
||||
var aggregateSupplementMediaTypes = []string{"movie", "series"}
|
||||
var aggregateSupplementMediaTypes = []string{"movie", "series", "audiobook"}
|
||||
|
||||
// ForYou returns personalised recommendations grouped by taste clusters.
|
||||
// For cold-start users, non-personalized rows are returned.
|
||||
|
||||
@@ -52,8 +52,8 @@ const tasteSeedCandidateQuery = `
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
|
||||
WHERE mi.status = 'matched'
|
||||
AND mi.type IN ('movie', 'series')
|
||||
WHERE (mi.status = 'matched' OR mi.type = 'audiobook')
|
||||
AND mi.type IN ('movie', 'series', 'audiobook')
|
||||
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'",
|
||||
"(mi.status = 'matched' OR mi.type = 'audiobook')",
|
||||
"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-watched series/movies over the given number of days.
|
||||
// GetPopularItems returns the most-played media items 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,11 +1270,13 @@ 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'
|
||||
WHERE (status = 'matched' OR type = 'audiobook')
|
||||
AND created_at > NOW() - ($1 || ' days')::interval
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
@@ -1325,7 +1327,7 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
|
||||
// GetTasteSeedCandidates returns movie/series/audiobook 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
|
||||
@@ -1393,7 +1395,7 @@ func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
|
||||
return genres, rows.Err()
|
||||
}
|
||||
|
||||
// GetGenreSamplerItems returns the most-watched series/movies in a specific genre.
|
||||
// GetGenreSamplerItems returns the most-played media items 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 (
|
||||
@@ -1614,7 +1616,7 @@ func (r *Repo) GetAllUsersWithTasteProfiles(ctx context.Context) ([]StaleProfile
|
||||
return profiles, rows.Err()
|
||||
}
|
||||
|
||||
// GetWatchedItemIDs returns content IDs of series/movies the user has watched
|
||||
// GetWatchedItemIDs returns content IDs of media items the user has watched
|
||||
// (>= 50% progress or completed). Episodes are resolved to their parent series.
|
||||
func (r *Repo) GetWatchedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
|
||||
@@ -24,51 +24,31 @@ type TagResource struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) {
|
||||
// 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) {
|
||||
var resources []RootFolderResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) {
|
||||
func ListQualityProfiles(ctx context.Context, client *Client) ([]QualityProfileResource, error) {
|
||||
var resources []QualityProfileResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) {
|
||||
func ListTags(ctx context.Context, client *Client) ([]TagResource, error) {
|
||||
var resources []TagResource
|
||||
if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// AcceptedWithoutResponse returns a FulfillmentResult marking the submission
|
||||
|
||||
@@ -28,3 +28,16 @@ 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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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",
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+54
-101
@@ -26,14 +26,10 @@ 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 == "" {
|
||||
@@ -471,13 +467,12 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
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`
|
||||
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`
|
||||
|
||||
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`)
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list request integrations: %w", err)
|
||||
}
|
||||
@@ -522,12 +517,20 @@ 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.Options == nil {
|
||||
i.Options = map[string]any{}
|
||||
if i.PluginConfig == nil {
|
||||
i.PluginConfig = map[string]any{}
|
||||
}
|
||||
options, err := json.Marshal(i.Options)
|
||||
pluginConfig, err := json.Marshal(i.PluginConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
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{}
|
||||
}
|
||||
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
|
||||
if err != nil {
|
||||
@@ -535,17 +538,13 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor
|
||||
}
|
||||
row := exec.QueryRow(ctx, `
|
||||
INSERT INTO request_integrations (
|
||||
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())
|
||||
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())
|
||||
RETURNING `+integrationColumns,
|
||||
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)
|
||||
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
|
||||
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
|
||||
out, err := r.scanIntegration(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request integration: %w", err)
|
||||
@@ -556,12 +555,20 @@ 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.Options == nil {
|
||||
i.Options = map[string]any{}
|
||||
if i.PluginConfig == nil {
|
||||
i.PluginConfig = map[string]any{}
|
||||
}
|
||||
options, err := json.Marshal(i.Options)
|
||||
pluginConfig, err := json.Marshal(i.PluginConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal options: %w", err)
|
||||
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{}
|
||||
}
|
||||
// Encrypt the incoming key; an empty result preserves the keep-existing CASE
|
||||
// (a blank edit leaves the stored key untouched).
|
||||
@@ -573,17 +580,12 @@ 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,
|
||||
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()
|
||||
capability_id=$6, installation_id=$7,
|
||||
supported_media_types=$8, plugin_config=$9, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING `+integrationColumns,
|
||||
i.ID, 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)
|
||||
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
|
||||
out, err := r.scanIntegration(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -594,9 +596,10 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -604,17 +607,6 @@ 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)
|
||||
@@ -669,21 +661,6 @@ 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 {
|
||||
@@ -794,15 +771,13 @@ type integrationScanner interface {
|
||||
|
||||
func (r *Repository) scanIntegration(row integrationScanner) (Integration, error) {
|
||||
var i Integration
|
||||
var quality, animeQuality sql.NullInt64
|
||||
var tags, animeTags []int32
|
||||
var optionsRaw []byte
|
||||
var installationID sql.NullInt64
|
||||
var pluginConfigRaw []byte
|
||||
var lastCheckAt sql.NullTime
|
||||
if err := row.Scan(
|
||||
&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,
|
||||
&i.ID, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
|
||||
&lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
|
||||
&i.CapabilityID, &installationID, &i.SupportedMediaTypes, &pluginConfigRaw,
|
||||
); err != nil {
|
||||
return Integration{}, err
|
||||
}
|
||||
@@ -814,42 +789,20 @@ 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 quality.Valid {
|
||||
v := int(quality.Int64)
|
||||
i.QualityProfileID = &v
|
||||
if installationID.Valid {
|
||||
v := int(installationID.Int64)
|
||||
i.InstallationID = &v
|
||||
}
|
||||
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 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 i.Options == nil {
|
||||
i.Options = map[string]any{}
|
||||
if i.PluginConfig == nil {
|
||||
i.PluginConfig = 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package requests
|
||||
|
||||
import "github.com/Silo-Server/silo-server/internal/access"
|
||||
|
||||
// plannedTarget is a routing decision: which instance, at which quality, with
|
||||
// which profile/folder/tags resolved (standard vs anime).
|
||||
type plannedTarget struct {
|
||||
Instance Integration
|
||||
Quality Quality
|
||||
IsAnime bool
|
||||
}
|
||||
|
||||
func integrationKindForMediaType(mediaType MediaType) string {
|
||||
if mediaType == MediaTypeSeries {
|
||||
return "sonarr"
|
||||
}
|
||||
return "radarr"
|
||||
}
|
||||
|
||||
// routeTargets decides the fulfillment targets for an approved request.
|
||||
// 1080p is always desired; 2160p is added when the requester's ceiling allows
|
||||
// 4K OR force-dual is on. A quality is emitted only if its default instance
|
||||
// exists for the kind.
|
||||
func routeTargets(req Request, ceiling string, settings Settings, instances []Integration) []plannedTarget {
|
||||
kind := integrationKindForMediaType(req.MediaType)
|
||||
|
||||
wants4K := settings.ForceDualQuality || access.CompareQuality(ceiling, access.PlaybackQuality4K) >= 0
|
||||
|
||||
var hd, uhd *Integration
|
||||
for i := range instances {
|
||||
in := instances[i]
|
||||
if in.Kind != kind || !in.Enabled {
|
||||
continue
|
||||
}
|
||||
if in.IsDefault && hd == nil {
|
||||
hd = &instances[i]
|
||||
}
|
||||
if in.IsDefault4K && uhd == nil {
|
||||
uhd = &instances[i]
|
||||
}
|
||||
}
|
||||
|
||||
var out []plannedTarget
|
||||
if hd != nil {
|
||||
out = append(out, plannedTarget{Instance: *hd, Quality: Quality1080p, IsAnime: req.IsAnime && hd.AnimeEnabled})
|
||||
}
|
||||
if wants4K && uhd != nil {
|
||||
out = append(out, plannedTarget{Instance: *uhd, Quality: Quality2160p, IsAnime: req.IsAnime && uhd.AnimeEnabled})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveInstance returns a copy of the instance with root folder / quality
|
||||
// profile / tags (and Sonarr series_type) set for standard vs anime fulfillment.
|
||||
func resolveInstance(pt plannedTarget) Integration {
|
||||
in := pt.Instance
|
||||
if in.Options == nil {
|
||||
in.Options = map[string]any{}
|
||||
} else {
|
||||
clone := make(map[string]any, len(in.Options))
|
||||
for k, v := range in.Options {
|
||||
clone[k] = v
|
||||
}
|
||||
in.Options = clone
|
||||
}
|
||||
if pt.IsAnime {
|
||||
// Anime fields are overrides: only replace the standard value when the
|
||||
// anime counterpart is set, so an admin can enable anime detection while
|
||||
// reusing the standard root folder / quality profile / tags for any field
|
||||
// they leave blank (rather than clearing them into an invalid submission).
|
||||
if in.AnimeRootFolder != "" {
|
||||
in.RootFolder = in.AnimeRootFolder
|
||||
}
|
||||
if in.AnimeQualityProfileID != nil {
|
||||
in.QualityProfileID = in.AnimeQualityProfileID
|
||||
}
|
||||
if len(in.AnimeTags) > 0 {
|
||||
in.Tags = in.AnimeTags
|
||||
}
|
||||
if in.Kind == "sonarr" {
|
||||
in.Options["series_type"] = "anime"
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package requests
|
||||
|
||||
import "testing"
|
||||
|
||||
func inst(kind, id string, def, def4k, anime bool) Integration {
|
||||
qp := 1
|
||||
return Integration{
|
||||
ID: id, Kind: kind, Name: id, Enabled: true, BaseURL: "http://x",
|
||||
APIKeyRef: "k", RootFolder: "/std", QualityProfileID: &qp,
|
||||
Is4K: def4k, IsDefault: def, IsDefault4K: def4k, AnimeEnabled: anime,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteTargets(t *testing.T) {
|
||||
hd := inst("radarr", "hd", true, false, false)
|
||||
uhd := inst("radarr", "uhd", false, true, false)
|
||||
hdAnime := inst("radarr", "hda", true, false, true)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req Request
|
||||
ceiling string
|
||||
force bool
|
||||
instances []Integration
|
||||
want []Quality
|
||||
wantAnime bool
|
||||
}{
|
||||
{"hd only, sd user", Request{MediaType: MediaTypeMovie}, "1080p", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
|
||||
{"4k user dual", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
|
||||
{"force dual overrides role", Request{MediaType: MediaTypeMovie}, "1080p", true, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
|
||||
{"4k user but no 4k default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd}, []Quality{Quality1080p}, false},
|
||||
{"no hd default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{uhd}, []Quality{Quality2160p}, false},
|
||||
{"anime on anime-enabled hd", Request{MediaType: MediaTypeMovie, IsAnime: true}, "1080p", false, []Integration{hdAnime}, []Quality{Quality1080p}, true},
|
||||
{"empty ceiling hd only", Request{MediaType: MediaTypeMovie}, "", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := routeTargets(tc.req, tc.ceiling, Settings{ForceDualQuality: tc.force}, tc.instances)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %d targets, want %d (%v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i, q := range tc.want {
|
||||
if got[i].Quality != q {
|
||||
t.Fatalf("target %d quality = %s, want %s", i, got[i].Quality, q)
|
||||
}
|
||||
if got[i].IsAnime != tc.wantAnime {
|
||||
t.Fatalf("target %d isAnime = %v, want %v", i, got[i].IsAnime, tc.wantAnime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+494
-292
@@ -27,30 +27,6 @@ type TMDBExternalIDClient interface {
|
||||
|
||||
const externalIDHydrationConcurrency = 4
|
||||
|
||||
type MovieFulfillmentAdapter interface {
|
||||
SubmitMovie(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error)
|
||||
}
|
||||
|
||||
type SeriesFulfillmentAdapter interface {
|
||||
SubmitSeries(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error)
|
||||
}
|
||||
|
||||
type MovieStatusAdapter interface {
|
||||
CheckMovieStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error)
|
||||
}
|
||||
|
||||
type SeriesStatusAdapter interface {
|
||||
CheckSeriesStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error)
|
||||
}
|
||||
|
||||
type MovieIntegrationOptionsAdapter interface {
|
||||
ListMovieIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error)
|
||||
}
|
||||
|
||||
type SeriesIntegrationOptionsAdapter interface {
|
||||
ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error)
|
||||
}
|
||||
|
||||
type EntitlementResolver interface {
|
||||
// MaxPlaybackQuality returns the requester's effective playback-quality
|
||||
// ceiling (already combining account- and profile-level caps). Empty string
|
||||
@@ -58,14 +34,20 @@ type EntitlementResolver interface {
|
||||
MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error)
|
||||
}
|
||||
|
||||
// RequesterIdentityResolver resolves a requesting user id into the identity a
|
||||
// per-user request_router plugin needs (e.g. Seerr attribution by email).
|
||||
type RequesterIdentityResolver interface {
|
||||
ResolveRequester(ctx context.Context, userID int) (email, username string, err error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
tmdb TMDBClient
|
||||
presence PresenceResolver
|
||||
movieAdapter MovieFulfillmentAdapter
|
||||
seriesAdapter SeriesFulfillmentAdapter
|
||||
entitlements EntitlementResolver
|
||||
Now func() time.Time
|
||||
store Store
|
||||
tmdb TMDBClient
|
||||
presence PresenceResolver
|
||||
router RequestRouterProvider
|
||||
entitlements EntitlementResolver
|
||||
requesterIdentity RequesterIdentityResolver
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type DiscoverySection struct {
|
||||
@@ -86,13 +68,28 @@ func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series SeriesFulfillmentAdapter) {
|
||||
s.movieAdapter = movie
|
||||
s.seriesAdapter = series
|
||||
}
|
||||
func (s *Service) SetRouterProvider(p RequestRouterProvider) { s.router = p }
|
||||
|
||||
func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r }
|
||||
|
||||
func (s *Service) SetRequesterIdentityResolver(r RequesterIdentityResolver) {
|
||||
s.requesterIdentity = r
|
||||
}
|
||||
|
||||
// populateRequesterIdentity fills req.RequesterEmail/Username from the resolver.
|
||||
// Nil resolver or any error leaves them empty (the plugin then behaves as admin).
|
||||
func (s *Service) populateRequesterIdentity(ctx context.Context, req *Request) {
|
||||
if s.requesterIdentity == nil || req.RequestedByUserID <= 0 {
|
||||
return
|
||||
}
|
||||
email, username, err := s.requesterIdentity.ResolveRequester(ctx, req.RequestedByUserID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "requests: requester identity resolve failed; attributing to admin", "user_id", req.RequestedByUserID, "error", err)
|
||||
return
|
||||
}
|
||||
req.RequesterEmail, req.RequesterUsername = email, username
|
||||
}
|
||||
|
||||
func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string {
|
||||
if s.entitlements == nil {
|
||||
return "" // no resolver -> unlimited (1080p baseline still applies)
|
||||
@@ -104,6 +101,94 @@ func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID st
|
||||
return q
|
||||
}
|
||||
|
||||
// allowedQualities returns the qualities a request may receive: 1080p always,
|
||||
// plus 2160p when force-dual is on or the requester's entitlement ceiling allows 4K.
|
||||
func (s *Service) allowedQualities(ctx context.Context, req Request, settings Settings) []Quality {
|
||||
out := []Quality{Quality1080p}
|
||||
ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID)
|
||||
// QualityAllowed treats an empty ceiling as "no cap" (the "Any" preset), so a
|
||||
// requester with unlimited playback quality correctly gets 4K. A raw
|
||||
// CompareQuality would rank "" as the LOWEST quality and wrongly drop 4K.
|
||||
if settings.ForceDualQuality || access.QualityAllowed(access.PlaybackQuality4K, ceiling) {
|
||||
out = append(out, Quality2160p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fulfillContext caches the global fulfillment inputs for one reconcile cycle
|
||||
// (or a single Approve/Retry) so integrations and settings are fetched once
|
||||
// instead of per request. API keys need no cache here: the repository decrypts
|
||||
// api_key_ref on read, so Integration.APIKeyRef already holds the literal key.
|
||||
type fulfillContext struct {
|
||||
integrations []Integration
|
||||
settings Settings
|
||||
}
|
||||
|
||||
func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error) {
|
||||
integrations, err := s.store.ListIntegrations(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fulfillContext{integrations: integrations, settings: settings}, nil
|
||||
}
|
||||
|
||||
// resolveRouterConnections turns enabled request_router integrations that serve
|
||||
// the given media type into ResolvedRouterConnections (api key resolved to
|
||||
// plaintext, plugin_config attached), and returns the installation+capability to
|
||||
// dispatch to.
|
||||
//
|
||||
// It filters by media type to match the integrationConfigured auto-approve gate
|
||||
// (so a series-only connection is never used for a movie request). Multi-
|
||||
// installation routing isn't supported yet: it picks the first eligible
|
||||
// connection's installation and includes ONLY connections belonging to it, so a
|
||||
// second installation's resolved plaintext credentials are never handed to the
|
||||
// first plugin. A connection whose api key cannot be resolved (or resolves empty)
|
||||
// is skipped rather than aborting the whole request — a sibling healthy
|
||||
// connection can still fulfill it, and an unauthenticated request is never sent.
|
||||
func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillContext, mediaType MediaType) ([]ResolvedRouterConnection, int, string, error) {
|
||||
var conns []ResolvedRouterConnection
|
||||
installationID, capabilityID := 0, ""
|
||||
chosen := false
|
||||
for _, in := range fc.integrations {
|
||||
if !eligibleRouterConnection(in, mediaType) {
|
||||
continue
|
||||
}
|
||||
// Contain to the first chosen (installation, capability): a plugin may
|
||||
// expose more than one request_router capability, and a connection of a
|
||||
// different capability must never be handed to the chosen one.
|
||||
if chosen && (*in.InstallationID != installationID || in.CapabilityID != capabilityID) {
|
||||
continue
|
||||
}
|
||||
// in.APIKeyRef was decrypted by the repo on read; empty means unconfigured.
|
||||
apiKey := strings.TrimSpace(in.APIKeyRef)
|
||||
if apiKey == "" {
|
||||
slog.WarnContext(ctx, "requests: skipping router connection with no api key", "connection_id", in.ID)
|
||||
continue
|
||||
}
|
||||
// Lock on the first SUCCESSFULLY resolved connection so a skipped
|
||||
// bad-key connection never pins the installation/capability.
|
||||
if !chosen {
|
||||
installationID, capabilityID, chosen = *in.InstallationID, in.CapabilityID, true
|
||||
}
|
||||
conns = append(conns, ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig})
|
||||
}
|
||||
return conns, installationID, capabilityID, nil
|
||||
}
|
||||
|
||||
// eligibleRouterConnection reports whether a connection is a candidate fulfillment
|
||||
// backend for the media type: enabled, bound to an installation, and naming a
|
||||
// capability sub-id that serves the media type. resolveRouterConnections (which
|
||||
// then resolves credentials) and integrationConfigured (the auto-approval gate)
|
||||
// share this predicate so the two cannot drift.
|
||||
func eligibleRouterConnection(in Integration, mediaType MediaType) bool {
|
||||
return in.Enabled && in.CapabilityID != "" && in.InstallationID != nil &&
|
||||
integrationSupportsMediaType(in, mediaType)
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) {
|
||||
if s == nil || s.store == nil || s.tmdb == nil {
|
||||
return nil, fmt.Errorf("request service is not configured")
|
||||
@@ -371,7 +456,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
|
||||
return nil, err
|
||||
}
|
||||
if req.Status == StatusApproved {
|
||||
return s.submitApprovedRequest(ctx, *req, viewer)
|
||||
return s.submitApprovedRequest(ctx, *req, viewer, nil)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -507,7 +592,7 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.submitApprovedRequest(ctx, *approved, viewer)
|
||||
return s.submitApprovedRequest(ctx, *approved, viewer, nil)
|
||||
}
|
||||
|
||||
func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
|
||||
@@ -576,69 +661,16 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request
|
||||
if req.Outcome != OutcomeFailed {
|
||||
return nil, ErrInvalidState
|
||||
}
|
||||
active, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested")
|
||||
if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// submitApprovedRequest only re-submits qualities lacking a healthy target, so
|
||||
// it is idempotent; gate it on the approved status it expects.
|
||||
active, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instances, err := s.store.ListIntegrations(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := s.store.ListTargets(ctx, active.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ceiling := s.requesterCeiling(ctx, active.RequestedByUserID, active.RequestedByProfileID)
|
||||
planned := routeTargets(*active, ceiling, settings, instances)
|
||||
if len(planned) == 0 {
|
||||
return s.markSubmissionFailed(ctx, active.ID, viewer,
|
||||
fmt.Errorf("no %s instance configured for the requested quality",
|
||||
integrationKindForMediaType(active.MediaType)))
|
||||
}
|
||||
|
||||
return s.submitPlannedTargets(ctx, *active, planned, existing, viewer)
|
||||
}
|
||||
|
||||
// submitPlannedTargets submits each planned target idempotently against the
|
||||
// already-recorded targets: a non-failed target for a quality is left alone, a
|
||||
// failed one is deleted and re-submitted, and a missing one is submitted fresh.
|
||||
// This is shared by Retry and the reconcile-driven submit so a re-run never
|
||||
// violates the UNIQUE(request_id, quality) constraint.
|
||||
func (s *Service) submitPlannedTargets(ctx context.Context, req Request, planned []plannedTarget, existing []Target, actor Viewer) (*Request, error) {
|
||||
hasOK := make(map[Quality]bool)
|
||||
for _, t := range existing {
|
||||
if t.Status != StatusFailed {
|
||||
hasOK[t.Quality] = true
|
||||
}
|
||||
}
|
||||
|
||||
latest := &req
|
||||
for _, pt := range planned {
|
||||
if hasOK[pt.Quality] {
|
||||
continue // healthy target already exists for this quality
|
||||
}
|
||||
// remove the stale failed target for this quality before re-submitting
|
||||
for _, t := range existing {
|
||||
if t.Quality == pt.Quality && t.Status == StatusFailed {
|
||||
if err := s.store.DeleteTarget(ctx, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
updated, err := s.submitPlannedTarget(ctx, req, pt, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated != nil {
|
||||
latest = updated
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
return s.submitApprovedRequest(ctx, *active, viewer, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) {
|
||||
@@ -652,12 +684,16 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe
|
||||
if err != nil {
|
||||
return ReconcileResult{}, err
|
||||
}
|
||||
fc, err := s.newFulfillContext(ctx)
|
||||
if err != nil {
|
||||
return ReconcileResult{}, err
|
||||
}
|
||||
result := ReconcileResult{Checked: len(candidates)}
|
||||
for _, req := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
change, err := s.reconcileRequest(ctx, *req)
|
||||
change, err := s.reconcileRequest(ctx, *req, fc)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "request reconcile failed",
|
||||
"request_id", req.ID,
|
||||
@@ -765,14 +801,17 @@ func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integ
|
||||
if !viewer.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if err := validateInstance(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := idgen.NextID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.ID = id
|
||||
if err := validateInstance(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateViaPlugin(ctx, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.SaveIntegrationWithDefaults(ctx, in, true)
|
||||
}
|
||||
|
||||
@@ -786,9 +825,86 @@ func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integ
|
||||
if err := validateInstance(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateViaPlugin(ctx, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.SaveIntegrationWithDefaults(ctx, in, false)
|
||||
}
|
||||
|
||||
// validateViaPlugin asks the bound request_router plugin to validate the
|
||||
// connection config on save. Field/form errors are surfaced as *ValidationError
|
||||
// so the API layer can render them inline.
|
||||
func (s *Service) validateViaPlugin(ctx context.Context, in Integration) error {
|
||||
if s.router == nil || in.InstallationID == nil {
|
||||
return nil
|
||||
}
|
||||
// On UPDATE the client omits api_key_ref ("leave blank to keep saved key"),
|
||||
// so we would otherwise validate against an empty credential. Mirror
|
||||
// LoadIntegrationOptions's backfill: load the stored row by id and reuse the
|
||||
// saved (already-decrypted) api key (and BaseURL/PluginConfig if also blank).
|
||||
// Nil-safe — a brand-new id has no stored row, so just proceed with what the
|
||||
// body carries.
|
||||
if strings.TrimSpace(in.APIKeyRef) == "" && strings.TrimSpace(in.ID) != "" {
|
||||
stored, err := s.store.GetIntegration(ctx, in.ID)
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if stored != nil {
|
||||
// Don't pair a stored API key with a caller-changed base URL: require the
|
||||
// key to be re-entered when the server URL changes (defense against
|
||||
// exfiltrating a stored, API-unreadable key to an attacker-supplied URL).
|
||||
if strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.BaseURL) != strings.TrimSpace(stored.BaseURL) {
|
||||
return &ValidationError{FieldErrors: map[string]string{"api_key_ref": "re-enter the API key when changing the base URL"}}
|
||||
}
|
||||
in.APIKeyRef = stored.APIKeyRef
|
||||
if strings.TrimSpace(in.BaseURL) == "" {
|
||||
in.BaseURL = stored.BaseURL
|
||||
}
|
||||
if in.PluginConfig == nil {
|
||||
in.PluginConfig = stored.PluginConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
// in.APIKeyRef is the decrypted literal (from the body, or backfilled from the
|
||||
// stored row above).
|
||||
apiKey := strings.TrimSpace(in.APIKeyRef)
|
||||
conn := ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig}
|
||||
siblings, err := s.siblingConnections(ctx, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fe, form, err := s.router.Validate(ctx, *in.InstallationID, in.CapabilityID, conn, siblings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fe) > 0 || form != "" {
|
||||
return &ValidationError{FieldErrors: fe, FormError: form}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// siblingConnections returns the other connections bound to the same plugin
|
||||
// installation as `in` (self excluded), carrying only id + config so a plugin
|
||||
// can enforce cross-connection rules without the host resolving sibling
|
||||
// credentials.
|
||||
func (s *Service) siblingConnections(ctx context.Context, in Integration) ([]ResolvedRouterConnection, error) {
|
||||
if in.InstallationID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.store.ListIntegrations(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ResolvedRouterConnection
|
||||
for _, other := range all {
|
||||
if other.ID == in.ID || other.InstallationID == nil || *other.InstallationID != *in.InstallationID {
|
||||
continue
|
||||
}
|
||||
out = append(out, ResolvedRouterConnection{ID: other.ID, Config: other.PluginConfig})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error {
|
||||
if !viewer.IsAdmin {
|
||||
return ErrForbidden
|
||||
@@ -797,78 +913,68 @@ func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id strin
|
||||
}
|
||||
|
||||
func validateInstance(in *Integration) error {
|
||||
in.Kind = strings.TrimSpace(in.Kind)
|
||||
if in.Kind != "radarr" && in.Kind != "sonarr" {
|
||||
return fmt.Errorf("%w: kind must be radarr or sonarr", ErrInvalidInput)
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if in.IsDefault && in.Is4K {
|
||||
return fmt.Errorf("%w: the HD default cannot be a 4K server", ErrInvalidInput)
|
||||
// capability_id carries the capability SUB-ID ("arr"/"seerr"), not the type:
|
||||
// the host resolves the plugin via requireCapability("request_router.v1", id),
|
||||
// which keys on (type, id), so storing the type "request_router.v1" here
|
||||
// resolves nothing. Matches the scan_source/metadata convention
|
||||
// (autoscan_sources.capability_id = "arr"). The bound plugin's Validate RPC is
|
||||
// the authority on whether the sub-id names a real capability.
|
||||
in.CapabilityID = strings.TrimSpace(in.CapabilityID)
|
||||
if in.CapabilityID == "" {
|
||||
return fmt.Errorf("%w: capability_id is required", ErrInvalidInput)
|
||||
}
|
||||
if in.IsDefault4K && !in.Is4K {
|
||||
return fmt.Errorf("%w: the 4K default must be a 4K server", ErrInvalidInput)
|
||||
if in.InstallationID == nil {
|
||||
return fmt.Errorf("%w: installation_id is required", ErrInvalidInput)
|
||||
}
|
||||
// The is_default/is_4k/is_default_4k cross-field consistency check is owned by
|
||||
// the request_router plugin's Validate RPC, which surfaces it as an inline
|
||||
// field error (better UX than a generic host 400). See validateViaPlugin.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) {
|
||||
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (map[string][]RouterOption, error) {
|
||||
if !viewer.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
// For a saved instance the request body carries only the path id (no kind and
|
||||
// often no creds), so resolve the saved row by id and backfill what the body
|
||||
// omitted. This makes "Test connection" reuse the correct per-instance key
|
||||
// (each kind can have multiple instances) instead of borrowing a sibling's.
|
||||
// For a saved instance the request body carries only the path id (no creds and
|
||||
// often no plugin wiring), so resolve the saved row by id and backfill what the
|
||||
// body omitted. This makes "Test connection" reuse the correct per-instance key
|
||||
// (each plugin can have multiple connections) instead of borrowing a sibling's.
|
||||
if id := strings.TrimSpace(integration.ID); id != "" && id != "new" {
|
||||
stored, err := s.store.GetIntegration(ctx, id)
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if stored != nil {
|
||||
if strings.TrimSpace(integration.Kind) == "" {
|
||||
integration.Kind = stored.Kind
|
||||
}
|
||||
submittedBaseURL := strings.TrimSpace(integration.BaseURL)
|
||||
storedBaseURL := strings.TrimSpace(stored.BaseURL)
|
||||
if strings.TrimSpace(integration.BaseURL) == "" {
|
||||
integration.BaseURL = stored.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(integration.APIKeyRef) == "" {
|
||||
if strings.TrimSpace(integration.APIKeyRef) == "" && (submittedBaseURL == "" || submittedBaseURL == storedBaseURL) {
|
||||
integration.APIKeyRef = stored.APIKeyRef
|
||||
}
|
||||
if strings.TrimSpace(integration.CapabilityID) == "" {
|
||||
integration.CapabilityID = stored.CapabilityID
|
||||
}
|
||||
if integration.InstallationID == nil {
|
||||
integration.InstallationID = stored.InstallationID
|
||||
}
|
||||
if integration.PluginConfig == nil {
|
||||
integration.PluginConfig = stored.PluginConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalized, err := normalizeIntegrationConnection(integration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(normalized.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput)
|
||||
}
|
||||
if strings.TrimSpace(normalized.APIKeyRef) == "" {
|
||||
return nil, fmt.Errorf("%w: api_key_ref is required", ErrInvalidInput)
|
||||
}
|
||||
// APIKeyRef is already the literal key: a saved instance was decrypted by
|
||||
// GetIntegration above; a new instance carries the key the admin just typed.
|
||||
resolved := normalized
|
||||
|
||||
switch resolved.Kind {
|
||||
case "radarr":
|
||||
adapter, ok := s.movieAdapter.(MovieIntegrationOptionsAdapter)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request radarr integration options are not configured")
|
||||
}
|
||||
return adapter.ListMovieIntegrationOptions(ctx, resolved)
|
||||
case "sonarr":
|
||||
adapter, ok := s.seriesAdapter.(SeriesIntegrationOptionsAdapter)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request sonarr integration options are not configured")
|
||||
}
|
||||
return adapter.ListSeriesIntegrationOptions(ctx, resolved)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput)
|
||||
apiKey := strings.TrimSpace(integration.APIKeyRef)
|
||||
if s.router == nil || integration.InstallationID == nil {
|
||||
return nil, fmt.Errorf("no fulfillment backend configured")
|
||||
}
|
||||
conn := ResolvedRouterConnection{ID: integration.ID, BaseURL: integration.BaseURL, APIKey: apiKey, Config: integration.PluginConfig}
|
||||
return s.router.ListConfigOptions(ctx, *integration.InstallationID, integration.CapabilityID, conn)
|
||||
}
|
||||
|
||||
func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) {
|
||||
@@ -1139,105 +1245,247 @@ func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, t
|
||||
return detectAnime(detail.KeywordIDs)
|
||||
}
|
||||
|
||||
// integrationConfigured reports whether a fulfillment backend exists for the
|
||||
// media type, gating auto-approval (pending vs approved). It uses the same
|
||||
// router-connection selection as resolveRouterConnections — an enabled
|
||||
// request_router.v1 connection with an installation — and additionally honors a
|
||||
// connection's declared media-type support so a movie request only auto-approves
|
||||
// when a router connection supporting "movie" exists.
|
||||
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
|
||||
instances, err := s.store.ListIntegrations(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
kind := integrationKindForMediaType(mediaType)
|
||||
for _, in := range instances {
|
||||
if in.Kind == kind && in.Enabled && (in.IsDefault || in.IsDefault4K) && integrationIsConfigured(in) {
|
||||
if eligibleRouterConnection(in, mediaType) &&
|
||||
strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.APIKeyRef) != "" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) {
|
||||
// integrationSupportsMediaType reports whether a router connection serves the
|
||||
// given media type. An empty SupportedMediaTypes is treated as "supports all".
|
||||
func integrationSupportsMediaType(in Integration, mediaType MediaType) bool {
|
||||
if len(in.SupportedMediaTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, mt := range in.SupportedMediaTypes {
|
||||
if mt == string(mediaType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) {
|
||||
if req.Outcome != OutcomeActive || req.Status != StatusApproved {
|
||||
return &req, nil
|
||||
}
|
||||
instances, err := s.store.ListIntegrations(ctx)
|
||||
if s.router == nil {
|
||||
return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured"))
|
||||
}
|
||||
if fc == nil {
|
||||
built, err := s.newFulfillContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fc = built
|
||||
}
|
||||
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(conns) == 0 {
|
||||
// Distinguish "no backend at all" from the migration breakage where an
|
||||
// existing connection row exists but its installation_id is NULL (the row
|
||||
// predates the plugin install and was never re-bound).
|
||||
msg := "no fulfillment backend configured"
|
||||
for _, in := range fc.integrations {
|
||||
if in.Enabled && in.CapabilityID != "" && in.InstallationID == nil {
|
||||
msg = "request backend connection is not bound to a plugin installation; re-save it in admin"
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
|
||||
}
|
||||
ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID)
|
||||
planned := routeTargets(req, ceiling, settings, instances)
|
||||
if len(planned) == 0 {
|
||||
return s.markSubmissionFailed(ctx, req.ID, actor,
|
||||
fmt.Errorf("no %s instance configured for the requested quality",
|
||||
integrationKindForMediaType(req.MediaType)))
|
||||
}
|
||||
|
||||
// Reconcile can re-run submit while the request is still 'approved'; skip
|
||||
// qualities that already have a live target and replace failed ones so the
|
||||
// UNIQUE(request_id, quality) constraint is never violated.
|
||||
existing, err := s.store.ListTargets(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.submitPlannedTargets(ctx, req, planned, existing, actor)
|
||||
}
|
||||
|
||||
// submitPlannedTarget creates a target row, submits it to the adapter, and
|
||||
// records the result (queued or failed) via UpdateTargetStatus (which recomputes
|
||||
// the request aggregate). Returns the latest request snapshot.
|
||||
func (s *Service) submitPlannedTarget(ctx context.Context, req Request, pt plannedTarget, actor Viewer) (*Request, error) {
|
||||
resolved := resolveInstance(pt)
|
||||
// The repo already decrypted the stored key; a blank one means unconfigured.
|
||||
if strings.TrimSpace(resolved.APIKeyRef) == "" {
|
||||
return s.createFailedTarget(ctx, req, pt, resolved, "missing api key", actor)
|
||||
healthy := map[Quality]bool{}
|
||||
for _, t := range existing {
|
||||
if t.Status != StatusFailed {
|
||||
healthy[t.Quality] = true
|
||||
}
|
||||
}
|
||||
|
||||
target, err := s.store.CreateTarget(ctx, Target{
|
||||
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
|
||||
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued,
|
||||
})
|
||||
allowed := s.allowedQualities(ctx, req, fc.settings)
|
||||
if !fc.settings.ForceDualQuality {
|
||||
allowed = filterUnconfiguredOptionalQualities(allowed, conns)
|
||||
}
|
||||
var want []Quality
|
||||
for _, q := range allowed {
|
||||
if !healthy[q] {
|
||||
want = append(want, q)
|
||||
}
|
||||
}
|
||||
if len(want) == 0 {
|
||||
return &req, nil
|
||||
}
|
||||
for _, t := range existing { // drop stale failed targets for the qualities we re-submit
|
||||
if t.Status == StatusFailed {
|
||||
for _, q := range want {
|
||||
if t.Quality == q {
|
||||
if err := s.store.DeleteTarget(ctx, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.populateRequesterIdentity(ctx, &req)
|
||||
targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, want, conns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, serr := s.submitTarget(ctx, req, resolved)
|
||||
if serr != nil {
|
||||
return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor)
|
||||
if len(targets) == 0 {
|
||||
if msg == "" {
|
||||
msg = "fulfillment backend created no targets"
|
||||
}
|
||||
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
|
||||
}
|
||||
return s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued,
|
||||
result.ExternalID, result.ExternalStatus, "", actor)
|
||||
connKind := connectionKindByID(conns)
|
||||
latest := &req
|
||||
// The plugin is an out-of-process trust boundary: validate every returned
|
||||
// target against the DB CHECK constraints (quality, status) and skip any
|
||||
// quality that is duplicated in the batch or already has a healthy target, so
|
||||
// a misbehaving plugin can't violate UNIQUE(request_id, quality) and wedge the
|
||||
// request.
|
||||
validQuality := map[Quality]bool{Quality1080p: true, Quality2160p: true}
|
||||
validStatus := map[Status]bool{StatusQueued: true, StatusDownloading: true, StatusCompleted: true, StatusFailed: true}
|
||||
returned := map[Quality]bool{}
|
||||
for _, rt := range targets {
|
||||
if !validQuality[rt.Quality] {
|
||||
slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "request_id", req.ID, "quality", string(rt.Quality))
|
||||
continue
|
||||
}
|
||||
if returned[rt.Quality] || healthy[rt.Quality] {
|
||||
continue // dup-in-batch, or a healthy target already exists for this quality
|
||||
}
|
||||
if rt.ConnectionID != "" {
|
||||
if _, ok := connKind[rt.ConnectionID]; !ok {
|
||||
slog.WarnContext(ctx, "requests: plugin returned unknown connection id; skipping target", "request_id", req.ID, "connection_id", rt.ConnectionID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
returned[rt.Quality] = true
|
||||
created, err := s.store.CreateTarget(ctx, Target{
|
||||
RequestID: req.ID, IntegrationID: rt.ConnectionID, IntegrationKind: connKind[rt.ConnectionID],
|
||||
Quality: rt.Quality, IsAnime: req.IsAnime, Status: StatusQueued,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := rt.Status
|
||||
if status == "" || !validStatus[status] {
|
||||
status = StatusQueued // coerce unknown/empty status to the DB-valid default
|
||||
}
|
||||
updated, err := s.store.UpdateTargetStatus(ctx, created.ID, status, rt.ExternalID, rt.ExternalStatus, rt.Message, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated != nil {
|
||||
latest = updated
|
||||
}
|
||||
}
|
||||
// Any wanted quality the plugin did not fulfill is recorded as a failed target
|
||||
// rather than silently dropped, so it stays visible and Retry re-attempts it
|
||||
// (a failed target is not "healthy").
|
||||
const noTargetMsg = "fulfillment backend returned no target for this quality"
|
||||
for _, q := range want {
|
||||
if returned[q] {
|
||||
continue
|
||||
}
|
||||
created, err := s.store.CreateTarget(ctx, Target{
|
||||
RequestID: req.ID, Quality: q, IsAnime: req.IsAnime, Status: StatusFailed, LastError: noTargetMsg,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.store.UpdateTargetStatus(ctx, created.ID, StatusFailed, "", "", noTargetMsg, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated != nil {
|
||||
latest = updated
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func (s *Service) createFailedTarget(ctx context.Context, req Request, pt plannedTarget, resolved Integration, msg string, actor Viewer) (*Request, error) {
|
||||
target, err := s.store.CreateTarget(ctx, Target{
|
||||
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
|
||||
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusFailed, LastError: msg,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// connectionKindByID maps each connection id to its plugin-declared service kind
|
||||
// (e.g. "radarr"/"sonarr") from PluginConfig["service_kind"], for the
|
||||
// integration_kind column on persisted targets. Missing kinds map to "".
|
||||
func connectionKindByID(conns []ResolvedRouterConnection) map[string]string {
|
||||
out := make(map[string]string, len(conns))
|
||||
for _, c := range conns {
|
||||
out[c.ID] = ""
|
||||
if c.Config != nil {
|
||||
if kind, ok := c.Config["service_kind"].(string); ok {
|
||||
out[c.ID] = kind
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", msg, actor)
|
||||
return out
|
||||
}
|
||||
|
||||
// submitTarget calls the correct adapter with a per-target Request copy. The
|
||||
// adapters read root folder/quality/tags (and Sonarr series_type) from the
|
||||
// resolved Integration.
|
||||
func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) {
|
||||
switch req.MediaType {
|
||||
case MediaTypeMovie:
|
||||
if s.movieAdapter == nil {
|
||||
return FulfillmentResult{}, fmt.Errorf("no movie adapter configured")
|
||||
func filterUnconfiguredOptionalQualities(qualities []Quality, conns []ResolvedRouterConnection) []Quality {
|
||||
out := make([]Quality, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
if q == Quality2160p && !routerQualityConfigured(q, conns) {
|
||||
continue
|
||||
}
|
||||
return s.movieAdapter.SubmitMovie(ctx, req, resolved)
|
||||
case MediaTypeSeries:
|
||||
if s.seriesAdapter == nil {
|
||||
return FulfillmentResult{}, fmt.Errorf("no series adapter configured")
|
||||
}
|
||||
return s.seriesAdapter.SubmitSeries(ctx, req, resolved)
|
||||
default:
|
||||
return FulfillmentResult{}, fmt.Errorf("unsupported media type %q", req.MediaType)
|
||||
out = append(out, q)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func routerQualityConfigured(q Quality, conns []ResolvedRouterConnection) bool {
|
||||
usesTieredDefaults := false
|
||||
for _, conn := range conns {
|
||||
if conn.Config == nil {
|
||||
continue
|
||||
}
|
||||
if hasRouterQualityKey(conn.Config) {
|
||||
usesTieredDefaults = true
|
||||
}
|
||||
if q == Quality2160p && boolConfig(conn.Config, "is_default_4k") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Generic request_router implementations may not expose arr-style HD/4K
|
||||
// default flags. In that case, preserve the host's requested qualities and
|
||||
// let the plugin decide what it can fulfill.
|
||||
return !usesTieredDefaults
|
||||
}
|
||||
|
||||
func hasRouterQualityKey(config map[string]any) bool {
|
||||
for _, key := range []string{"is_default", "is_default_4k", "is_4k"} {
|
||||
if _, ok := config[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func boolConfig(config map[string]any, key string) bool {
|
||||
v, ok := config[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) {
|
||||
@@ -1259,7 +1507,7 @@ const (
|
||||
reconcileFailed reconcileChange = "failed"
|
||||
)
|
||||
|
||||
func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) {
|
||||
func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext) (reconcileChange, error) {
|
||||
completed, err := s.requestAvailable(ctx, req)
|
||||
if err != nil {
|
||||
return reconcileUnchanged, err
|
||||
@@ -1286,7 +1534,7 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
|
||||
}
|
||||
|
||||
if req.Status == StatusApproved {
|
||||
updated, err := s.submitApprovedRequest(ctx, req, Viewer{})
|
||||
updated, err := s.submitApprovedRequest(ctx, req, Viewer{}, fc)
|
||||
if err != nil {
|
||||
return reconcileUnchanged, err
|
||||
}
|
||||
@@ -1304,41 +1552,51 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
|
||||
if err != nil {
|
||||
return reconcileUnchanged, err
|
||||
}
|
||||
instances, err := s.store.ListIntegrations(ctx)
|
||||
if s.router == nil {
|
||||
return reconcileUnchanged, nil
|
||||
}
|
||||
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
|
||||
if err != nil {
|
||||
return reconcileUnchanged, err
|
||||
}
|
||||
byID := make(map[string]Integration, len(instances))
|
||||
for _, in := range instances {
|
||||
byID[in.ID] = in
|
||||
if len(conns) == 0 {
|
||||
return reconcileUnchanged, nil
|
||||
}
|
||||
change := reconcileUnchanged
|
||||
|
||||
var refs []RouterTargetRef
|
||||
for _, t := range targets {
|
||||
if t.Status == StatusCompleted || t.Status == StatusFailed {
|
||||
continue
|
||||
}
|
||||
in, ok := byID[t.IntegrationID]
|
||||
if !ok {
|
||||
refs = append(refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID})
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return reconcileUnchanged, nil
|
||||
}
|
||||
|
||||
statuses, err := s.router.CheckStatus(ctx, installationID, capabilityID, req, refs, conns)
|
||||
if err != nil {
|
||||
return reconcileUnchanged, err
|
||||
}
|
||||
|
||||
change := reconcileUnchanged
|
||||
for _, st := range statuses {
|
||||
// Match the returned status to the live target by (quality, connection).
|
||||
var target *Target
|
||||
for i := range targets {
|
||||
if targets[i].Quality == st.Quality && targets[i].IntegrationID == st.ConnectionID {
|
||||
target = &targets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil || target.Status == StatusCompleted || target.Status == StatusFailed {
|
||||
continue
|
||||
}
|
||||
// in.APIKeyRef was decrypted by ListIntegrations; skip if unconfigured.
|
||||
if strings.TrimSpace(in.APIKeyRef) == "" {
|
||||
newStatus := st.Status
|
||||
if newStatus == "" || newStatus == target.Status {
|
||||
continue
|
||||
}
|
||||
probe := req
|
||||
probe.ExternalID = t.ExternalID
|
||||
st, err := s.checkFulfillmentStatus(ctx, probe, in)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st.Status == "" && st.Outcome == "" {
|
||||
continue
|
||||
}
|
||||
newStatus := targetStatusFromFulfillment(st)
|
||||
if newStatus == t.Status {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.UpdateTargetStatus(ctx, t.ID, newStatus, st.ExternalID, st.ExternalStatus, "", Viewer{}); err != nil {
|
||||
if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil {
|
||||
return reconcileUnchanged, err
|
||||
}
|
||||
switch newStatus {
|
||||
@@ -1357,20 +1615,6 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
|
||||
return change, nil
|
||||
}
|
||||
|
||||
func targetStatusFromFulfillment(st FulfillmentStatus) Status {
|
||||
switch st.Status {
|
||||
case StatusCompleted:
|
||||
return StatusCompleted
|
||||
case StatusDownloading:
|
||||
return StatusDownloading
|
||||
default:
|
||||
if st.Outcome == OutcomeFailed {
|
||||
return StatusFailed
|
||||
}
|
||||
return StatusQueued
|
||||
}
|
||||
}
|
||||
|
||||
// hasLiveTargets reports whether the request has any non-terminal (queued or
|
||||
// downloading) fulfillment target.
|
||||
func (s *Service) hasLiveTargets(ctx context.Context, requestID string) (bool, error) {
|
||||
@@ -1394,33 +1638,6 @@ func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, erro
|
||||
return matches[req.TMDBID].Available, nil
|
||||
}
|
||||
|
||||
func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request, resolved Integration) (FulfillmentStatus, error) {
|
||||
switch req.MediaType {
|
||||
case MediaTypeMovie:
|
||||
checker, ok := s.movieAdapter.(MovieStatusAdapter)
|
||||
if !ok {
|
||||
return FulfillmentStatus{}, nil
|
||||
}
|
||||
return checker.CheckMovieStatus(ctx, req, resolved)
|
||||
case MediaTypeSeries:
|
||||
checker, ok := s.seriesAdapter.(SeriesStatusAdapter)
|
||||
if !ok {
|
||||
return FulfillmentStatus{}, nil
|
||||
}
|
||||
return checker.CheckSeriesStatus(ctx, req, resolved)
|
||||
default:
|
||||
return FulfillmentStatus{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func integrationIsConfigured(integration Integration) bool {
|
||||
return integration.Enabled &&
|
||||
strings.TrimSpace(integration.BaseURL) != "" &&
|
||||
strings.TrimSpace(integration.APIKeyRef) != "" &&
|
||||
strings.TrimSpace(integration.RootFolder) != "" &&
|
||||
integration.QualityProfileID != nil
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
@@ -1529,21 +1746,6 @@ func normalizeUserLimit(limit UserLimit) (UserLimit, error) {
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func normalizeIntegrationConnection(integration Integration) (Integration, error) {
|
||||
integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind))
|
||||
switch integration.Kind {
|
||||
case "radarr", "sonarr":
|
||||
default:
|
||||
return Integration{}, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput)
|
||||
}
|
||||
integration.BaseURL = strings.TrimRight(strings.TrimSpace(integration.BaseURL), "/")
|
||||
integration.APIKeyRef = strings.TrimSpace(integration.APIKeyRef)
|
||||
if integration.Options == nil {
|
||||
integration.Options = map[string]any{}
|
||||
}
|
||||
return integration, nil
|
||||
}
|
||||
|
||||
func normalizeMediaType(mediaType MediaType) (MediaType, error) {
|
||||
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
|
||||
case MediaTypeMovie:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user