Compare commits
4
Commits
019caca1b3
...
a039ce0276
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a039ce0276 | ||
|
|
7cf1ece63c | ||
|
|
f763d7fdb3 | ||
|
|
5e4f8d5d2b |
@@ -95,7 +95,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
|
|||||||
| `welcome_email` | 8 | | | |
|
| `welcome_email` | 8 | | | |
|
||||||
|
|
||||||
Remaining numeric enums are unresolved gaps and stay numeric pending verification: `VoteType [0,1]` (labels unverified), `RequestSource`, `RequestLimitType [0,1,2]`, and the legacy `orderType`/`statusType`/`availabilityType` filter ints. Search response IDs must retain their source/provider namespace, including multi-search TV TMDB IDs.
|
Remaining numeric enums are unresolved gaps and stay numeric pending verification: `VoteType [0,1]` (labels unverified), `RequestSource`, `RequestLimitType [0,1,2]`, and the legacy `orderType`/`statusType`/`availabilityType` filter ints. Search response IDs must retain their source/provider namespace, including multi-search TV TMDB IDs.
|
||||||
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results. Return `parent_request_id` alongside child IDs when present.
|
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results (the v2 list contract is preserved; upstream 500 errors surface natively). Return `parent_request_id` alongside child IDs when present. `read_requests.search` for TV uses v1 `Request/tv/search/{term}` but falls back to a bounded v1 parent scan if it fails, compensating for an upstream LINQ bug.
|
||||||
6. Parameter names such as `currentPosition`, `position`, `skip`, `count`, `take` and `amountToLoad` are mapped exactly per ledger. Offsets are zero-based by this MCP contract; the adapter must verify ambiguous upstream paging behaviour. Requests use count then position; issue summary uses position then take; issue list uses take then skip. Do not reverse these pairs.
|
6. Parameter names such as `currentPosition`, `position`, `skip`, `count`, `take` and `amountToLoad` are mapped exactly per ledger. Offsets are zero-based by this MCP contract; the adapter must verify ambiguous upstream paging behaviour. Requests use count then position; issue summary uses position then take; issue list uses take then skip. Do not reverse these pairs.
|
||||||
7. Array responses without server pagination are sliced locally only within a bounded fetched response. Mark pagination as local and total unknown unless the complete collection was obtained. A result-size cap is a truncation warning, not a fabricated server total or a promise that the next page exists.
|
7. Array responses without server pagination are sliced locally only within a bounded fetched response. Mark pagination as local and total unknown unless the complete collection was obtained. A result-size cap is a truncation warning, not a fabricated server total or a promise that the next page exists.
|
||||||
8. `format: date-time` and cross-field comparisons must be enforced by the server, not assumed from a client's validator. Stats requires `from <= to` when both are supplied. Strings must contain non-whitespace text where used as queries/comments. Reject duplicate season numbers and duplicate episode numbers; impose a maximum of 2,000 selected episodes per call in addition to per-array limits.
|
8. `format: date-time` and cross-field comparisons must be enforced by the server, not assumed from a client's validator. Stats requires `from <= to` when both are supplied. Strings must contain non-whitespace text where used as queries/comments. Reject duplicate season numbers and duplicate episode numbers; impose a maximum of 2,000 selected episodes per call in addition to per-array limits.
|
||||||
@@ -111,8 +111,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
|
|||||||
|
|
||||||
`advanced_movie` POSTs the exact DiscoverModel property names: `type:"movie"`, `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, and decade unchanged. Ombi 4.53.x accepts but ignores `companies`, so `company_ids` is not advertised and legacy calls fail explicitly. Do not invent a `query` requirement for discovery. Release year and decade must agree if both are supplied.
|
`advanced_movie` POSTs the exact DiscoverModel property names: `type:"movie"`, `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, and decade unchanged. Ombi 4.53.x accepts but ignores `companies`, so `company_ids` is not advertised and legacy calls fail explicitly. Do not invent a `query` requirement for discovery. Release year and decade must agree if both are supplied.
|
||||||
|
|
||||||
`read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route — the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias of moviedb despite its parameter name and must never serve a TVDB lookup. IMDb path placeholder spelling differs from its parameter declaration; substitute the actual path placeholder. `by_request` chooses movie/request, tv/request or artist/request; the TV namespace of this particular upstream route requires adapter verification and must not be guessed from list results. `movie_localized` uses POST v1 movie/info with `{theMovieDbId, languageCode}`. `ratings` uses title and year, not a numeric media ID. `streaming` uses TMDB even for TV. Cast/crew are projections of detailed metadata where present, not invented standalone endpoints.
|
`read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route (the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias and must never serve a TVDB lookup). The legacy v1 TVDB info route does not reliably carry request state; for TVDB, the adapter performs a bounded scan of v1 `Request/tv` (matching by `tvDbId` or `imdbId`) to overlay `requested`, `request_targets` (using the real parent request ID), and per-episode availability flags. IMDb path placeholder spelling differs from its parameter declaration. `by_request` chooses movie/request, tv/request or artist/request. `movie_localized` uses POST v1 movie/info. `ratings` uses title and year. `streaming` uses TMDB even for TV.
|
||||||
|
|
||||||
TV identifier labels follow the origin route, not the upstream field name. The v1 TVMaze-backed routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) place the TVDB id in the misleadingly-named `theMovieDbId` field and the TVMaze id in `seriesId`; emit `tvdb` and `tvmaze` there. The v2 engine routes (browse, `moviedb`, `by_request`, multi-search) are TMDB-keyed; emit `tmdb` and do not treat `seriesId` as TVMaze. When `theMovieDbId` is absent, `id` is labelled with that origin namespace so browse and collection members still carry a usable identifier. Multi-search `mediaType` is case-insensitive (`Artist`→artist). The v1 `RecentlyAdded` TV model's id namespace is unverified — keep `tmdb` pending live confirmation.
|
TV identifier labels follow the origin route, not the upstream field name. The v1 TVMaze-backed routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) place the TVDB id in the misleadingly-named `theMovieDbId` field and the TVMaze id in `seriesId`; emit `tvdb` and `tvmaze` there. The v2 engine routes (browse, `moviedb`, `by_request`, multi-search) are TMDB-keyed; emit `tmdb` and do not treat `seriesId` as TVMaze. When `theMovieDbId` is absent, `id` is labelled with that origin namespace so browse and collection members still carry a usable identifier. Multi-search `mediaType` is case-insensitive (`Artist`→artist). The v1 `RecentlyAdded` TV model's id namespace is unverified — keep `tmdb` pending live confirmation.
|
||||||
|
|
||||||
`read_reference.keywords` passes query `searchTerm`; keyword detail uses its own keyword ID. Watch-provider catalogue search also has optional `searchTerm`; it is distinct from streaming availability for a particular title. Other branches have no request body. Reference values are good optional cached resources, but remain available through tools for tool-only clients.
|
`read_reference.keywords` passes query `searchTerm`; keyword detail uses its own keyword ID. Watch-provider catalogue search also has optional `searchTerm`; it is distinct from streaming availability for a particular title. Other branches have no request body. Reference values are good optional cached resources, but remain available through tools for tool-only clients.
|
||||||
|
|||||||
@@ -298,3 +298,9 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
|
|||||||
- Input/output schema contracts in `docs/schema/` unchanged.
|
- Input/output schema contracts in `docs/schema/` unchanged.
|
||||||
- `go test ./...` output is identical to before the phase (package fully behind the tag).
|
- `go test ./...` output is identical to before the phase (package fully behind the tag).
|
||||||
- No instance URL in committed files; `.env` guidance points to the gitignored file only.
|
- No instance URL in committed files; `.env` guidance points to the gitignored file only.
|
||||||
|
|
||||||
|
## M5 Findings — Request state and list routes (#7, #10)
|
||||||
|
|
||||||
|
- `#7`: `GET /api/v1/Search/tv/info/{tvdbId}` (TVMaze-backed info route) upstream does not set request state flags accurately, returning `requested: false` regardless of truth. The adapter patches this by performing a bounded `GET /api/v1/Request/tv` parent scan and overlaying per-episode availability and request states.
|
||||||
|
- `#10`: `GET /api/v2/Requests/{movie,tv,album}/...` (v2 lists) encounter a per-row `NullReferenceException` on non-empty results (observed on Ombi 4.53.10). The adapter surfaces this as `UPSTREAM_REJECTED` rather than silently replacing it with v1 `tvlite` payloads, preserving the expected v2 child-page contract.
|
||||||
|
- `#10` search fallback: `GET /api/v1/Request/tv/search/{term}` on 4.53.10 suffers from a LINQ translation bug (upstream Ombi-app/Ombi#5420, fixed by #5421). The adapter gracefully falls back to a bounded v1 parent scan filtering locally on the term if the primary search route fails.
|
||||||
|
|||||||
@@ -1472,3 +1472,117 @@ func TestAPIKeyMode(t *testing.T) {
|
|||||||
t.Errorf("ApiKey header never reached upstream")
|
t.Errorf("ApiKey header never reached upstream")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- M5 request state and list routes ---
|
||||||
|
|
||||||
|
func TestM5TVDBDetailsRequestStateOverlay(t *testing.T) {
|
||||||
|
mock := newMockOmbi(t, "jwt")
|
||||||
|
c := spawnServer(t, mock.env())
|
||||||
|
c.handshake(t)
|
||||||
|
|
||||||
|
out := c.callTool(t, "read_media", map[string]any{
|
||||||
|
"action": "details",
|
||||||
|
"target": map[string]any{"media": "tv", "provider": "tvdb", "id": 81189},
|
||||||
|
})
|
||||||
|
data := requireOK(t, out)
|
||||||
|
|
||||||
|
var m struct {
|
||||||
|
Items []struct {
|
||||||
|
Requested *bool `json:"requested"`
|
||||||
|
RequestTargets []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"request_targets"`
|
||||||
|
Seasons []struct {
|
||||||
|
SeasonNumber int `json:"season_number"`
|
||||||
|
Episodes []struct {
|
||||||
|
EpisodeNumber int `json:"episode_number"`
|
||||||
|
Requested *bool `json:"requested"`
|
||||||
|
Available *bool `json:"available"`
|
||||||
|
} `json:"episodes"`
|
||||||
|
} `json:"seasons"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &m)
|
||||||
|
if len(m.Items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(m.Items))
|
||||||
|
}
|
||||||
|
it := m.Items[0]
|
||||||
|
|
||||||
|
if it.Requested == nil || !*it.Requested {
|
||||||
|
t.Errorf("expected Requested=true from overlay")
|
||||||
|
}
|
||||||
|
if len(it.RequestTargets) != 1 || it.RequestTargets[0].Kind != "tv_parent" || it.RequestTargets[0].ID != 42 {
|
||||||
|
t.Errorf("expected RequestTargets=[{tv_parent 42}], got %v", it.RequestTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ep1Requested, ep1Available bool
|
||||||
|
for _, s := range it.Seasons {
|
||||||
|
if s.SeasonNumber == 1 {
|
||||||
|
for _, e := range s.Episodes {
|
||||||
|
if e.EpisodeNumber == 1 {
|
||||||
|
if e.Requested != nil { ep1Requested = *e.Requested }
|
||||||
|
if e.Available != nil { ep1Available = *e.Available }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ep1Requested {
|
||||||
|
t.Errorf("expected season 1 episode 1 Requested=true")
|
||||||
|
}
|
||||||
|
if !ep1Available {
|
||||||
|
t.Errorf("expected season 1 episode 1 Available=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if calls := mock.countCalls("GET", "/api/v1/Search/tv/info/"); calls != 1 {
|
||||||
|
t.Errorf("expected 1 call to v1 info route, got %d", calls)
|
||||||
|
}
|
||||||
|
if calls := mock.countCalls("GET", "/api/v1/Request/tv/"); calls < 1 {
|
||||||
|
t.Errorf("expected >=1 call to v1 tv parent list route, got %d", calls)
|
||||||
|
}
|
||||||
|
if calls := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/"); calls != 0 {
|
||||||
|
t.Errorf("expected 0 calls to v2 TMDB alias route, got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestM5TVSearchFallback(t *testing.T) {
|
||||||
|
mock := newMockOmbi(t, "jwt")
|
||||||
|
c := spawnServer(t, mock.env())
|
||||||
|
c.handshake(t)
|
||||||
|
|
||||||
|
out := c.callTool(t, "read_requests", map[string]any{
|
||||||
|
"action": "search",
|
||||||
|
"media": "tv",
|
||||||
|
"query": "trigger-500",
|
||||||
|
})
|
||||||
|
data := requireOK(t, out)
|
||||||
|
var page struct {
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &page)
|
||||||
|
if len(page.Items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item from fallback, got %d", len(page.Items))
|
||||||
|
}
|
||||||
|
if page.Items[0].Title != "trigger-500 and something" {
|
||||||
|
t.Errorf("expected item title 'trigger-500 and something', got %q", page.Items[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestM5V2List500ErrorSurfaced(t *testing.T) {
|
||||||
|
mock := newMockOmbi(t, "jwt")
|
||||||
|
c := spawnServer(t, mock.env())
|
||||||
|
c.handshake(t)
|
||||||
|
|
||||||
|
out := c.callTool(t, "read_media", map[string]any{
|
||||||
|
"action": "details",
|
||||||
|
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 500001},
|
||||||
|
})
|
||||||
|
if !out.IsError {
|
||||||
|
t.Fatalf("expected error on 500")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.Text, "System.NullReferenceException") {
|
||||||
|
t.Errorf("expected error message to contain sanitized detail, got %q", out.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
|
|||||||
mux.HandleFunc("GET /api/v1/Status", m.wrap(m.fixed(`200`)))
|
mux.HandleFunc("GET /api/v1/Status", m.wrap(m.fixed(`200`)))
|
||||||
mux.HandleFunc("GET /api/v1/Status/info", m.wrap(m.fixed(`"mock-status-info"`)))
|
mux.HandleFunc("GET /api/v1/Status/info", m.wrap(m.fixed(`"mock-status-info"`)))
|
||||||
mux.HandleFunc("GET /api/v1/Settings/about", m.wrap(m.json(mockAbout())))
|
mux.HandleFunc("GET /api/v1/Settings/about", m.wrap(m.json(mockAbout())))
|
||||||
|
mux.HandleFunc("GET /api/v1/Request/tv/{count}/{pos}/{o}/{s}/{a}", m.wrap(m.tvParentList))
|
||||||
mux.HandleFunc("GET /api/v2/Requests/movie/{amt}/{pos}/requestDate/{order}", m.wrap(m.movieList))
|
mux.HandleFunc("GET /api/v2/Requests/movie/{amt}/{pos}/requestDate/{order}", m.wrap(m.movieList))
|
||||||
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList))
|
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList))
|
||||||
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
|
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
|
||||||
@@ -358,7 +359,7 @@ func mockTVDetail() map[string]any {
|
|||||||
map[string]any{
|
map[string]any{
|
||||||
"seasonNumber": 1,
|
"seasonNumber": 1,
|
||||||
"episodes": []any{
|
"episodes": []any{
|
||||||
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true, "requested": true},
|
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true},
|
||||||
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "available": true},
|
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "available": true},
|
||||||
map[string]any{"episodeNumber": 3, "title": "...And the Bag's in the River"},
|
map[string]any{"episodeNumber": 3, "title": "...And the Bag's in the River"},
|
||||||
map[string]any{"episodeNumber": 4, "title": "Cancer Man"},
|
map[string]any{"episodeNumber": 4, "title": "Cancer Man"},
|
||||||
@@ -532,6 +533,35 @@ func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pos := r.PathValue("pos")
|
||||||
|
if pos != "0" {
|
||||||
|
m.json([]any{})(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.json([]any{
|
||||||
|
map[string]any{
|
||||||
|
"id": 42,
|
||||||
|
"tvDbId": 81189,
|
||||||
|
"title": "trigger-500 and something", // For search fallback test
|
||||||
|
"childRequests": []any{
|
||||||
|
map[string]any{
|
||||||
|
"seasonRequests": []any{
|
||||||
|
map[string]any{
|
||||||
|
"seasonNumber": 1,
|
||||||
|
"episodes": []any{
|
||||||
|
map[string]any{"episodeNumber": 1, "requested": true},
|
||||||
|
map[string]any{"episodeNumber": 2, "requested": true},
|
||||||
|
map[string]any{"episodeNumber": 3, "requested": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
|
func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
@@ -552,6 +582,10 @@ func (m *mockOmbi) movieDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
|
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
|
||||||
"System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" +
|
"System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" +
|
||||||
"Authorization: Bearer should-never-appear</body></html>"))
|
"Authorization: Bearer should-never-appear</body></html>"))
|
||||||
|
case "500001":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error": "Object reference not set to an instance of an object. System.NullReferenceException"}`))
|
||||||
default:
|
default:
|
||||||
m.jsonErr(w, http.StatusNotFound, "Movie not found")
|
m.jsonErr(w, http.StatusNotFound, "Movie not found")
|
||||||
}
|
}
|
||||||
@@ -595,6 +629,13 @@ func (m *mockOmbi) tvInfoTVDB(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockOmbi) tvSearch(w http.ResponseWriter, r *http.Request) {
|
func (m *mockOmbi) tvSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.PathValue("term") == "trigger-500" {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
|
||||||
|
"System.NullReferenceException at Ombi.Core.Engine.TvRequestEngine</body></html>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
m.json([]any{mockTVSearchHit()})(w, r)
|
m.json([]any{mockTVSearchHit()})(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (o *op) httpErr(resp *http.Response, raw []byte) *ToolResult {
|
|||||||
default:
|
default:
|
||||||
if st >= 500 {
|
if st >= 500 {
|
||||||
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
|
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
|
||||||
fmt.Sprintf("upstream error (HTTP %d)", st), true
|
fmt.Sprintf("upstream error (HTTP %d)", st)+sanitizedDetail(raw), true
|
||||||
} else {
|
} else {
|
||||||
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
|
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
|
||||||
fmt.Sprintf("upstream returned HTTP %d", st), false
|
fmt.Sprintf("upstream returned HTTP %d", st), false
|
||||||
|
|||||||
+12
-1
@@ -128,8 +128,19 @@ func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
|
|||||||
if fail != nil {
|
if fail != nil {
|
||||||
return fail
|
return fail
|
||||||
}
|
}
|
||||||
|
it := project(m)
|
||||||
|
|
||||||
|
// #7 request state overlay for tvdb
|
||||||
|
if t.Media == "tv" && t.Provider == "tvdb" {
|
||||||
|
reqVal := jbool(m, "requested")
|
||||||
|
if (reqVal == nil || !*reqVal) && jint(m, "requestId") == nil {
|
||||||
|
id, _ := t.idInt()
|
||||||
|
o.overlayTVRequestState(&it, id, jstr(m, "imdbId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return o.ok(&MediaPage{Kind: "media_page",
|
return o.ok(&MediaPage{Kind: "media_page",
|
||||||
Items: []Media{project(m)}, Page: singlePage(1, "media")})
|
Items: []Media{it}, Page: singlePage(1, "media")})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *op) mediaByRequest(a *mediaCallArgs) *ToolResult {
|
func (o *op) mediaByRequest(a *mediaCallArgs) *ToolResult {
|
||||||
|
|||||||
@@ -145,3 +145,65 @@ func TestProjectRequestListStillUsesEntityID(t *testing.T) {
|
|||||||
t.Errorf("target.id = %d, want 10", r.Target.ID)
|
t.Errorf("target.id = %d, want 10", r.Target.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeTVRequestState(t *testing.T) {
|
||||||
|
it := &Media{
|
||||||
|
Seasons: []SeasonOut{
|
||||||
|
{
|
||||||
|
SeasonNumber: 1,
|
||||||
|
Episodes: []EpisodeOut{
|
||||||
|
{EpisodeNumber: 1},
|
||||||
|
{EpisodeNumber: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
parent := map[string]any{
|
||||||
|
"id": 909,
|
||||||
|
"tvDbId": 81189,
|
||||||
|
"childRequests": []any{
|
||||||
|
map[string]any{
|
||||||
|
"seasonRequests": []any{
|
||||||
|
map[string]any{
|
||||||
|
"seasonNumber": 1,
|
||||||
|
"episodes": []any{
|
||||||
|
map[string]any{"episodeNumber": 1, "requested": true, "available": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := mergeTVRequestState(it, parent, 81189, "")
|
||||||
|
if !matched {
|
||||||
|
t.Fatalf("expected match")
|
||||||
|
}
|
||||||
|
if it.Requested == nil || !*it.Requested {
|
||||||
|
t.Errorf("expected media Requested=true")
|
||||||
|
}
|
||||||
|
if len(it.RequestTargets) != 1 || it.RequestTargets[0].ID != 909 {
|
||||||
|
t.Errorf("expected target ID 909")
|
||||||
|
}
|
||||||
|
if it.Seasons[0].Episodes[0].Requested == nil || !*it.Seasons[0].Episodes[0].Requested {
|
||||||
|
t.Errorf("expected ep1 Requested=true")
|
||||||
|
}
|
||||||
|
if it.Seasons[0].Episodes[0].Available == nil || !*it.Seasons[0].Episodes[0].Available {
|
||||||
|
t.Errorf("expected ep1 Available=true")
|
||||||
|
}
|
||||||
|
if it.Seasons[0].Episodes[1].Requested != nil {
|
||||||
|
t.Errorf("expected ep2 Requested=nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeTVRequestStateNoMatch(t *testing.T) {
|
||||||
|
it := &Media{}
|
||||||
|
parent := map[string]any{"tvDbId": 99999}
|
||||||
|
matched := mergeTVRequestState(it, parent, 81189, "")
|
||||||
|
if matched {
|
||||||
|
t.Fatalf("expected no match")
|
||||||
|
}
|
||||||
|
if it.Requested != nil {
|
||||||
|
t.Errorf("expected Requested=nil on no match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// read_requests — v2 list/status routes, single gets, TV children,
|
// read_requests — v2 list/status routes, single gets, TV children,
|
||||||
@@ -163,6 +164,25 @@ func (o *op) requestsSearch(a *RequestsListArgs) *ToolResult {
|
|||||||
return o.invalid("media", "media must be movie|tv|album")
|
return o.invalid("media", "media must be movie|tv|album")
|
||||||
}
|
}
|
||||||
raw, fail := o.call("GET", path+seg(a.Query), nil, nil)
|
raw, fail := o.call("GET", path+seg(a.Query), nil, nil)
|
||||||
|
|
||||||
|
// #10 tv search fallback
|
||||||
|
if fail != nil && a.Media == "tv" {
|
||||||
|
items := []Request{}
|
||||||
|
q := strings.ToLower(a.Query)
|
||||||
|
_, failScan := o.eachTVRequestParent(func(p map[string]any) bool {
|
||||||
|
if strings.Contains(strings.ToLower(jstr(p, "title")), q) {
|
||||||
|
items = append(items, o.projectRequest(p, kind))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if failScan != nil {
|
||||||
|
return fail // surface the original error
|
||||||
|
}
|
||||||
|
o.warnf("primary tv search route failed; fell back to parent scan")
|
||||||
|
win, pg := localWindow(o, items, a.Page, "requests")
|
||||||
|
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
|
||||||
|
}
|
||||||
|
|
||||||
if fail != nil {
|
if fail != nil {
|
||||||
return fail
|
return fail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eachTVRequestParent iterates GET /api/v1/Request/tv/{count}/{pos}/1/0/0
|
||||||
|
// pages (filters ignored upstream; orderType 1 keeps the path valid).
|
||||||
|
// fn(parent) returning true stops the scan. Returns truncated=true when
|
||||||
|
// the 10-page cap is hit without exhausting the list.
|
||||||
|
func (o *op) eachTVRequestParent(fn func(map[string]any) bool) (truncated bool, err *ToolResult) {
|
||||||
|
count := 100
|
||||||
|
pos := 0
|
||||||
|
pages := 0
|
||||||
|
for pages < 10 {
|
||||||
|
path := fmt.Sprintf("/api/v1/Request/tv/%d/%d/1/0/0", count, pos)
|
||||||
|
raw, fail := o.call("GET", path, nil, nil)
|
||||||
|
if fail != nil {
|
||||||
|
return false, fail
|
||||||
|
}
|
||||||
|
arr, fail := o.decodeArray(raw)
|
||||||
|
if fail != nil {
|
||||||
|
return false, fail
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range arr {
|
||||||
|
if fn(m) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(arr) < count {
|
||||||
|
return false, nil // exhausted
|
||||||
|
}
|
||||||
|
pos += count
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return true, nil // hit cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeTVRequestState attempts to match and apply request state from a parent record p.
|
||||||
|
// Returns true if the parent matched (stopping the scan).
|
||||||
|
func mergeTVRequestState(it *Media, p map[string]any, tvdbID int, imdbID string) bool {
|
||||||
|
match := false
|
||||||
|
if jint(p, "tvDbId") != nil && *jint(p, "tvDbId") == tvdbID {
|
||||||
|
match = true
|
||||||
|
} else if imdbID != "" && jstr(p, "imdbId") == imdbID {
|
||||||
|
match = true
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
bTrue := true
|
||||||
|
it.Requested = &bTrue
|
||||||
|
it.RequestTargets = []OutTarget{{Kind: "tv_parent", ID: *jint(p, "id")}}
|
||||||
|
|
||||||
|
// Overlay season/episode states
|
||||||
|
childRequests := jarr(p, "childRequests")
|
||||||
|
for _, cr := range childRequests {
|
||||||
|
crm, ok := cr.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seasonRequests := jarr(crm, "seasonRequests")
|
||||||
|
for _, sr := range seasonRequests {
|
||||||
|
srm, ok := sr.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sNum := jint(srm, "seasonNumber")
|
||||||
|
if sNum == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching season in 'it'
|
||||||
|
var season *SeasonOut
|
||||||
|
for i := range it.Seasons {
|
||||||
|
if it.Seasons[i].SeasonNumber == *sNum {
|
||||||
|
season = &it.Seasons[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if season == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes := jarr(srm, "episodes")
|
||||||
|
for _, ep := range episodes {
|
||||||
|
epm, ok := ep.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
epNum := jint(epm, "episodeNumber")
|
||||||
|
if epNum == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching episode
|
||||||
|
for i := range season.Episodes {
|
||||||
|
if season.Episodes[i].EpisodeNumber == *epNum {
|
||||||
|
season.Episodes[i].Requested = &bTrue
|
||||||
|
if avail := jbool(epm, "available"); avail != nil && *avail {
|
||||||
|
season.Episodes[i].Available = &bTrue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *op) overlayTVRequestState(it *Media, tvdbID int, imdbID string) {
|
||||||
|
truncated, fail := o.eachTVRequestParent(func(p map[string]any) bool {
|
||||||
|
return mergeTVRequestState(it, p, tvdbID, imdbID)
|
||||||
|
})
|
||||||
|
|
||||||
|
if fail != nil || truncated {
|
||||||
|
o.warnf("request state scan incomplete: degraded upstream flags kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user