Repository navigation
Description
Drafted with AI assistance; measurements, branch and tests verified by the reporter before posting.
Feature request
I'm submitting a proposal to ...
- new feature
- change an existing feature
Describe your request
Restore the artwork that disappeared with the Falcor schema changes (#1781,
#1792) by adding a persisted-query GraphQL client for
https://www.netflix.com/graphql, together with a resilience layer that turns
all-or-nothing pathEvaluator 404s into partial results instead of failing the
whole batch.
State: implemented on a branch — 143 unit tests, pylint clean, pushed to my
fork. Not opened as a PR yet; I would like direction first, also because
#1796 is open and takes a different route.
Describe the use case
Today the artwork nodes are retired, and because pathEvaluator is
all-or-nothing per batch, a single dead path fails the whole request — the
visible symptom is empty or half-empty menus. Two adjacent requests show the
fault in two lines:
["videos",81605886,"summary"]→ 200["videos",81605886,"boxarts",["_665x375"],"jpg","value"]→ 404
Netflix' GraphQL endpoint serves non-browser clients — this is measured, not
assumed. docs/graphql-probe.sh posts a persisted query with
User-Agent: Kodi/20.0, three session cookies and no browser fingerprint
headers, and receives complete artwork at the same dimensions seen in the
captures. The script is re-runnable against any HAR.
Verified across seven captures (773 MB, three days):
- 21 persisted-query IDs;
version: 102in all 601 recorded requests. A wrong
UUID fails loudly rather than silently returning wrong artwork. - Auth failures arrive as HTTP 200 with
errors: [UNAUTHENTICATED], not
401. A client trusting the status code would return empty artwork silently —
this shaped the transport design.
Describe an alternative
#1796 (rollback) is the alternative currently on the table. It removes
ART_PARTIAL_PATHS from all batches and drops fields. Honest price: only the
poster survives (via the remaining itemSummary card) — no clearlogo, no
fanart. And three fields it removes are actively read by this codebase:
| Field | Consumer |
|---|---|
dpSupplementalMessage |
resources/lib/kodi/infolabels.py:94 |
sequiturEvidence |
resources/lib/services/nfsession/directorybuilder/dir_builder_utils.py:62 |
requestId |
resources/lib/utils/api_paths.py:87, dir_builder_utils.py:65 |
Also worth a look before relying on it: #1796 rolls the profile switch back to
/SwitchProfile, but the Shakti profile endpoints return 404 in the captures
(/api/shakti/mre/profiles/switch, /profileLock).
Development tips
- Branch in my fork (based on master
7accc24c, v1.23.5):
https://github.com/shithead/plugin.video.netflix/tree/netflix-api-rework
(commit7bb323cd) - Compare against upstream master:
https://github.com/CastagnaIT/plugin.video.netflix/compare/master...shithead:netflix-api-rework - Full write-up for maintainers:
docs/graphql-artwork.md
— design, measurements, deliberate non-goals (full text attached below) - Probe script:
docs/graphql-probe.sh,
test setup:
docs/testing.md
What the branch contains:
- Persisted-query GraphQL client; three sibling error classes (so an expired
session cannot be swallowed); one bounded retry after session refresh. - Halving layer plus per-session blacklist for retired Falcor paths — a 404
becomes a partial result with a log line instead of a failed batch. - Artwork mapping:
sdp→ thumb/landscape,StoryArt→ fanart,
LOGO_BRANDED_HORIZONTAL→ clearlogo (unbranded fallback); extraction scoped
to the requested entity. - 143 tests in 13 files, mutation-validated; pure-Python Kodi stubs, so
pytest tests/unitruns without Kodi.pylint resources/lib/ tests/clean.
Not wired yet: get_video_art has no production caller. Connecting it is a
separate change (IPC slot vs in-session caller, weighed against get_art's
persistent per-video cache).
Additional details or screenshots (if appropriate)
Specific details
Three decisions I don't want to guess at:
- Wiring
get_video_art(see above) — maintainer call. - The surviving
itemSummarycard is 342×192 (landscape). It currently books
posterso the slot is not empty; the spec sayslandscape. - PR slicing. Proposal: (a) test harness alone, (b) resilience + data-shape
changes, (c) GraphQL artwork. Happy to send any subset.
Caveat: tests run against recorded fixtures, not against Netflix. Whether
version: 102 is still accepted cannot be shown by a test — it needs one live
run, which I can document.
Ask: Should this be discussed first (here / in #1792 / in #1796), or should
I prepare a PR — and in which slices?
Appendix: full text of docs/graphql-artwork.md
Expand the maintainer write-up — docs/graphql-artwork.md (full text)
Moving artwork from Falcor to GraphQL
Netflix retired the artwork nodes from its Falcor schema. This document
describes the GraphQL client that replaces them, what was measured rather
than assumed, and what is deliberately left open.
State: implementation complete, landed as a foundation. 143 tests,
pylint resources/lib/ tests/ clean.
1. The finding this rests on
Netflix' GraphQL endpoint serves non-browser clients.
docs/graphql-probe.sh sends a persisted-query POST to
https://www.netflix.com/graphql with User-Agent: Kodi/20.0, three session
cookies and no browser fingerprint headers. It receives complete artwork, at
exactly the dimensions seen in the recorded captures.
This was measured on 2026-10-03, not inferred. The script is committed and
re-runnable against a HAR capture, so the claim is reproducible rather than
asserted. Before that probe the approach was a hypothesis; afterwards it is a
measurement anyone can repeat in two minutes.
1a. The underlying fault, and its scope
The artwork removal is one symptom of a wider Falcor contraction, reported in
issues #1781 and #1792. The cleanest single demonstration is two adjacent path
requests against the same video:
["videos",81605886,"summary"] -> 200
["videos",81605886,"boxarts",["_665x375"],"jpg","value"] -> 404
The summary path is served; the artwork path beneath it is gone. That is the
whole fault in two lines — no batch-level analysis needed to see it.
Two further endpoints are also retired:
/api/shakti/mre/profiles/switch -> 404
/api/shakti/mre/profiles/profileLock -> 404
Profile switching and the PIN prompt are therefore broken independently of
artwork. This matters for PR #1796, which rolls the profile switch back to
/SwitchProfile: if the Shakti path is dead, the rollback target is a
different question worth settling before that PR is relied on. The graphql
useProfilesSummaryQuery operation in this branch is a candidate replacement,
but unlike the artwork work it has not been proven end to end here.
2. What the recorded traffic establishes
Derived from seven captures (773 MB) covering three days of activity:
| Fact | Value | Why it matters |
|---|---|---|
| Operation IDs | 21 persisted-query UUIDs | Not guessable. A wrong UUID returns an error, not silently wrong artwork. |
| Persisted version | 102 in all 601 recorded requests |
Consistent across three days. |
| Artwork source | Image nodes |
itemSummary.assets does not exist — zero occurrences across all seven captures. |
| Image key format | <ArtType>|<uuid>|<locale>[|variant] |
The uuid segment is not reliably a UUID. Never validate it as one. |
| Auth failure shape | HTTP 200 with errors: [UNAUTHENTICATED] |
See §5. This drives the whole transport design. |
3. What it replaces
The Falcor artwork nodes are gone from the schema. Concretely:
ART_PARTIAL_PATHSis now empty — the add-on requests no retired artwork
nodes at all.ArtType-keyedImagenodes supply artwork instead.- A halving layer (
resources/lib/services/nfsession/session/path_blacklist.py)
tolerates retired Falcor paths in general.pathEvaluatoris all-or-nothing
per batch, so a single dead path failed the whole request; on a 404 the batch
is now halved and the rejected paths are blacklisted for the session.
4. Deliberate divergence from PR #1796
Three fields that PR #1796 removes are actively read by this codebase:
| Field | Consumer |
|---|---|
dpSupplementalMessage |
resources/lib/kodi/infolabels.py:94 |
sequiturEvidence |
resources/lib/services/nfsession/directorybuilder/dir_builder_utils.py:62 |
requestId |
resources/lib/utils/api_paths.py:87, dir_builder_utils.py:65 |
Removing them as #1796 does would break four features. Only requestId and
evidence are removed here, and both removals are pinned by tests.
5. The transport, and why it is shaped this way
Netflix signals an expired session as HTTP 200 with an errors array, not
as a 401. A client that trusts the status code returns empty artwork silently —
no exception, no log entry. That is worse than having no client at all, because
it hides the failure.
Every response is therefore checked for errors before data is parsed, with
one bounded retry after try_refresh_session_data(). The three failure classes
are siblings with no subclassing, so except GraphQLRequestError cannot swallow
an expired session.
Two details that the recorded traffic and the existing session layer forced:
post_safereturns the decoded body (a dict for JSON, bytes otherwise),
not arequests.Response— seesession/http_requests.py:114-119. Every
caller treats it as a dict. A 401 also never reaches the caller as a 401:
the session layer raisesHttpError401first, so the client translates it.requests.exceptions.JSONDecodeErrorinherits from both
RequestExceptionandValueError. TheValueErrorclause is matched first,
or a malformed body is misreported as a transport failure instead of a
protocol failure.
Session composition — one requests.Session, not two:
class SessionPathRequests(SessionGraphQLRequests, GraphQLClient):SessionGraphQLRequests supplies post_safe via SessionAccess;
GraphQLClient supplies the request machinery. Both derive from
SessionHTTPRequests, so client and request path share one session.
The 'graphql' endpoint is registered with is_api_call: False. The GraphQL
host is always www.netflix.com and must not follow the runtime
build_identifier — that is the failure mode of issue #1428.
6. Artwork extraction
sdp -> thumb, landscape
StoryArt -> fanart
LOGO_BRANDED_HORIZONTAL -> clearlogo (LOGO_UNBRANDED_HORIZONTAL as fallback)
Two refinements the captures forced:
- Size cutoff.
sdparrives twice, at 342×192 and 665×375. A cutoff keeps
the preview card. The consequence is documented in code rather than glossed
over: if Netflix ever serves only 342-wide cards,thumbis empty rather than
a degraded image. - Entity scoping. The request enables
videoMerchEnabled,
hasPromoVideoOverrideandcheckLinearChannel, and merch entries carry
their ownunifiedEntityId. A document-wide walk would let a promo video's
still outrank the requested title's on width. Extraction is scoped to the
requested entity instead.
The Falcor itemSummary card survives as the poster fallback. See §8 for why
that booking is not settled.
7. Tests
143 tests in 13 files, runnable with pytest tests/unit:
| Area | Tests |
|---|---|
| GraphQL artwork mapping | 37 |
| GraphQL transport | 24 |
| Artwork sources | 10 |
| API paths | 9 |
| GraphQL wiring | 9 |
| Path blacklist and resilience | 16 |
| Directory builder, data types, pruning, import surface | 30 |
The suite is validated by mutation testing: known-broken variants were
introduced deliberately and each is caught by a named test. Several tests exist
because mutants survived an earlier round — the ranking in _pick was entirely
unpinned until a follow-up round added the tests that kill it, and one
transport test was passing vacuously against a fake that did not mirror what
post_safe actually returns.
CI runs pylint resources/lib/ tests/ and exits non-zero on any message. The
branch is clean.
8. What is deliberately not done
get_video_art has no production caller yet. The client is reachable from
the session hierarchy and tested, but connecting it to a consumer is a separate
change. It is not mechanical: get_videoid_info is the only in-session caller
of get_art and holds self, while add_info_list_item runs in the add-on
process and would need a new IPC slot. It also has to be weighed against
get_art's cache, which persists art per video and language. This is a call
for maintainers, not something to guess at.
The poster vs landscape booking is open. The surviving itemSummary
card is 342×192 — landscape, not a poster — and the spec says it belongs in
landscape. It is currently booked to poster, because the slot must not stay
empty while the GraphQL source is unavailable. The code states this rather than
claiming the question is settled.
Tests run against fixtures, not against Netflix. Whether version: 102 is
still accepted cannot be shown by any test in this repository.
9. Also not attempted
SearchPageQueryResults— three-level result tree with its own types,
needs its own abstraction.PreviewModal*episode queries — cursor paging, different data model.SetEntityThumbRating— a mutation that changes account state. That decision
belongs to the user, not to a client library.CarouselPage— the existing Falcorlistspath carries the same data.
Activity