From 8a86e0cf083e3f03e3516faabf392d6a1b2cc558 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 00:27:39 -0400 Subject: [PATCH] refactor: tmdb and requests polish - GetExternalIDs now uses the dedicated /movie/{id}/external_ids and /tv/{id}/external_ids endpoints instead of fetching the full detail with append_to_response=external_ids. The dedicated payload is one or two orders of magnitude smaller for the same fields. - Document PosterPath/BackdropPath on MediaResult as raw TMDB path fragments that callers must prefix with the image base URL. - normalizeCast switches from inline insertion sort to sort.SliceStable. The output is identical; the new form is one line and O(n log n). - normalizeIntegration no longer reuses integration.Tags' backing array via Tags[:0]; the slice is callable code, so reusing the array would silently corrupt the caller's slice if it kept a reference. Allocate a fresh slice instead. - HandleGet now requires a profile, matching the rest of the /requests user-group handlers. Router middleware enforces this already, but the inline check is defense-in-depth for any future remount. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/handlers/requests.go | 2 +- internal/metadata/tmdb/client.go | 20 +++++++++++--------- internal/metadata/tmdb/client_test.go | 11 +++-------- internal/metadata/tmdb/types.go | 9 +++++---- internal/requests/service.go | 2 +- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 69d93046..ed908a2c 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -265,7 +265,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) } func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { - viewer, ok := requestViewer(w, r, false) + viewer, ok := requestViewer(w, r, true) if !ok { return } diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index cacf6a48..89ef8a6f 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "strings" "time" @@ -885,11 +886,9 @@ func normalizeCast(cast []castEntry) []MediaCastMember { } sorted := make([]castEntry, len(cast)) copy(sorted, cast) - for i := 1; i < len(sorted); i++ { - for j := i; j > 0 && sorted[j].Order < sorted[j-1].Order; j-- { - sorted[j], sorted[j-1] = sorted[j-1], sorted[j] - } - } + sort.SliceStable(sorted, func(i, j int) bool { + return sorted[i].Order < sorted[j].Order + }) const maxCast = 24 if len(sorted) > maxCast { sorted = sorted[:maxCast] @@ -972,20 +971,23 @@ func pickTVRating(cr *contentRatingsResponse) string { } // GetExternalIDs fetches external IDs for a TMDB movie or TV entry. +// Uses the dedicated external_ids endpoint instead of append_to_response on +// the full detail, which would return a 100+ KB payload to extract a handful +// of identifiers. func (c *Client) GetExternalIDs(ctx context.Context, mediaType string, id int) (*ExternalIDs, error) { var path string switch mediaType { case "movie": - path = fmt.Sprintf("/movie/%d?append_to_response=external_ids", id) + path = fmt.Sprintf("/movie/%d/external_ids", id) case "tv": - path = fmt.Sprintf("/tv/%d?append_to_response=external_ids", id) + path = fmt.Sprintf("/tv/%d/external_ids", id) default: return nil, fmt.Errorf("tmdb: invalid media type: %q", mediaType) } - var resp externalIDsResponse + var resp ExternalIDs if err := c.doGet(ctx, path, &resp); err != nil { return nil, err } - return resp.ExternalIDs, nil + return &resp, nil } diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index fe888168..288dbc9d 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -637,23 +637,18 @@ func TestGetCollectionRejectsNonPositiveID(t *testing.T) { func TestGetExternalIDs(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/movie/123" { + if r.URL.Path != "/movie/123/external_ids" { http.NotFound(w, r) return } - if got := r.URL.Query().Get("append_to_response"); got != "external_ids" { - t.Fatalf("append_to_response = %q, want external_ids", got) - } if got := r.URL.Query().Get("api_key"); got != "test-key" { t.Fatalf("api_key query = %q, want test-key", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "external_ids": { - "imdb_id": "tt0133093", - "tvdb_id": 12345 - } + "imdb_id": "tt0133093", + "tvdb_id": 12345 }`)) })) defer server.Close() diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index ae482e6d..e3cffe4a 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -37,6 +37,11 @@ type CollectionResult struct { // MediaResult is a normalized TMDB movie or TV result for request search and // discovery surfaces. MediaType is Silo-facing: "movie" or "series". +// +// PosterPath and BackdropPath are raw TMDB path fragments (e.g. "/abc.jpg"). +// Callers must compose the full URL by prepending the TMDB image base +// (https://image.tmdb.org/t/p/{size}); the values are not browser-loadable +// on their own. type MediaResult struct { ID int MediaType string @@ -157,10 +162,6 @@ type mediaMultiSearchResponse struct { VoteAverage float64 `json:"vote_average"` } -type externalIDsResponse struct { - ExternalIDs *ExternalIDs `json:"external_ids"` -} - type apiError struct { StatusMessage string `json:"status_message"` StatusCode int `json:"status_code"` diff --git a/internal/requests/service.go b/internal/requests/service.go index abe5e0e2..6f0d31fe 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -1236,7 +1236,7 @@ func normalizeIntegration(integration Integration) (Integration, error) { if integration.QualityProfileID != nil && *integration.QualityProfileID <= 0 { return Integration{}, fmt.Errorf("%w: quality_profile_id must be positive", ErrInvalidInput) } - filteredTags := integration.Tags[:0] + filteredTags := make([]int, 0, len(integration.Tags)) for _, tag := range integration.Tags { if tag > 0 { filteredTags = append(filteredTags, tag)