Skip to content

Fix series, seasons and movies lost to unexpected provider data - #302

Open
achevalet wants to merge 10 commits into
Kevinjil:masterfrom
achevalet:fix-provider-compatibility
Open

achevalet wants to merge 10 commits into
Kevinjil:masterfrom
achevalet:fix-provider-compatibility

Conversation

@achevalet

@achevalet achevalet commented Oct 2, 2026 •

Copy link
Copy Markdown

Summary

Fixes for provider data which made series, seasons or movies disappear, and for a cache key which re-crawled the provider on every restart. Fixes only, no new features. This branch targets 10.11 like master.

Changes

  • Unexpected JSON shapes (Relax Xtream API requirements #214, Cannot convert invalid value to System.DateTime. Path '[0].last_modified' #287): a series or movie whose response deviated from the schema was dropped in full: [] instead of {}, "episodes": [], a non-numeric episodes key such as "specials", or a field of the wrong type. A member that cannot be parsed is now skipped and logged, keeping the rest. For Cannot convert invalid value to System.DateTime. Path '[0].last_modified' #287, the provider's last_modified date strings failed the listing of the whole category.
  • releasedate: an object or an array instead of a date failed the whole movie or episode details, even through the error handler; it is now read as no date.
  • Series without cast or genres (Series NULL #267): "cast": null or "genre": null threw a NullReferenceException, which emptied the whole category.
  • Missing episodes: some providers store every episode under one key of the episodes map, so seasons were missing. Episodes are now grouped by the season they report, falling back to the key. This is the bug Fix missing episodes when stored under different dictionary key #284 addressed, with a different approach. A season folder built before this change still resolves through the key it was built with, instead of appearing empty.
  • Unparseable ids: with members now skipped, an id like "n/a" or null would become 0, giving an item which cannot be played. These items are dropped, and their number is logged.
  • Channel cache key: DataVersion used Assembly.GetCallingAssembly() and Configuration.GetHashCode(), so it changed on every restart and invalidated every cached channel. It now uses the plugin assembly and a hash of the configuration's contents. Updating to this version invalidates the cache once.

Adds a test project (xUnit, net9.0) covering these cases, with samples from the issues.

Testing

On this branch: 599 tests pass, and every commit builds against Jellyfin 10.11. Tested with a real provider on Jellyfin 10.11.11 (Docker) and 12.1 (Debian package).

Written with Claude Code (Claude Opus 5.5).

🤖 Generated with Claude Code

achevalet and others added 10 commits October 2, 2026 22:14
Providers return a variety of junk for `releasedate`. The client's error
handler skips a string which is not a date, as the property is nullable,
but an object or an array still fails the whole movie or episode details:
the reader throws on it even with the error handled.

Add SafeDateTimeConverter, which reads the whole token and yields null for
any token that does not hold a usable date, and apply it to the
`releasedate` of both VodInfo and EpisodeInfo. It only supports nullable
targets, as there is no meaningful date to fall back to. It follows
OnlyObjectConverter, the existing converter for the same class of problem:
the read is narrowed and the write is left to the serializer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A series or movie whose response deviated from the expected schema was
discarded in full: providers return `[]` instead of `{}` for an item with
no data, `"episodes": []` for a series with no episodes, a non-numeric
episodes key such as "specials", or an occasional field of the wrong type.
Any one of these lost the entire item, including everything that did
parse.

Handle the two cases separately:

- A response whose root is not an object carries no item at all. The two
  endpoints which return a single object, get_series_info and get_vod_info,
  now share a guard which inspects the root token and returns null for it,
  logging which shape arrived, so callers can skip just that item. Their
  callers handle null accordingly.

- A failure on a single member is recoverable. The deserialization error
  handler already skipped members whose target property is nullable; it now
  skips any member, leaving it at its default and keeping the rest of the
  response. Unexpected members are logged at warning level, while a nullable
  property without a usable value stays at debug as before. An error at the
  root of the document has no member to skip, so it is left to propagate and
  the guard above reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DataVersion is the cache key for the plugin's channels, so it must change
when the configuration changes and hold still otherwise. It did neither
reliably.

Assembly.GetCallingAssembly() in a property getter returns whichever
assembly happened to call it and is sensitive to inlining, so the version
component could come from Jellyfin rather than from the plugin. Use the
plugin assembly explicitly.

Configuration.GetHashCode() is an object identity hash. It changes when the
configuration object is replaced, but it also changes on every restart, so
every cached channel was invalidated and the whole provider re-crawled each
time Jellyfin started. Derive the component from the serialized contents of
the configuration instead, so it depends on the configuration and nothing
else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some providers bucket every episode under a single arbitrary key of the
`episodes` map, so the key says one season while the episodes themselves
report another. Those seasons were missing from the library: the season
list was built from the map's keys, so only the keys ever appeared as
folders, and the episodes inside them carried a season number that
disagreed with the folder they were in.

Group the episodes by the season they report, and build both the season
list and the episode list from that one grouping, so the two cannot
disagree. The fallback to the map's key is per episode rather than per
series: providers populate `season` unevenly, and an episode whose season
is missing or unparseable has to stay with the bucket it came from instead
of collecting in a season 0 that does not exist.

Report the season an episode was resolved into as its ParentIndexNumber
rather than the season the episode claims. For providers which omit
`season` the claimed value is 0 on every episode, which told Jellyfin that
each one belonged to season 0 no matter which folder it appeared in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The behaviour these fixes depend on is entirely a matter of what a provider
sends back, which is awkward to reach through the Jellyfin plugin host, so
none of it was covered.

Add a xUnit project exercising the parts that decide what ends up in the
library: the release date converter, the shared guard and error handling for
malformed single-object responses, the grouping which backs both the season
and the episode listing, and the configuration component of the channel
cache key. Each provider response shape which caused a reported problem is
present as a test case.

The helpers the tests reach for are already internal, so the plugin assembly
gains InternalsVisibleTo rather than widening anything further.

The project is marked non-publishable: JPRM publishes every project in the
solution into a single output directory, so without it the test binaries
land next to the plugin in the release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deserialization error handler skips a member it cannot parse and leaves
it at its default, so an id which arrived as "n/a" or a JSON null becomes 0.
That is indistinguishable from real data further down, and surfaces in the
library as a channel, movie, series or episode which cannot be played.

Xtream ids count up from one, so drop anything lower where the listings are
built rather than letting it through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dropping an item whose id could not be parsed is silent, so a provider
sending bad ids shows up only as a library which is quietly smaller than it
should be. Report the count at warning level for each listing, and for the
episodes of a series, so the cause is visible in the log.

StreamService now takes an ILogger. The episode grouping is static and is
also called from the tests, so it takes one only when the caller has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A season folder carries the season id it was built with, and GetEpisodes
looked that id up in a freshly computed grouping. When the two disagree, as
they do for a folder cached before the grouping changed, the lookup missed
and the season was returned empty. An empty season reads as a series with
nothing to play, and asking Jellyfin to play it posts PlaybackInfo against
the folder itself, which throws because a folder carries no media source.

Fall back to the episodes stored under that key, and report it, so a stale
folder resolves instead of appearing empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Providers send `"cast": null` or `"genre": null` for some series, which
overrides the empty default of these properties. Splitting them then threw
a NullReferenceException, which failed the listing of the whole category,
so it showed no series at all (Kevinjil#267). An empty or blank value did not fail
but gave the series a person or genre with an empty name.

Read null as no value, and drop empty entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some providers send `last_modified` as a date string instead of a Unix
timestamp, which failed the listing of every series of the category
(Kevinjil#287). Since "Keep parsing when a provider returns an unexpected JSON
shape", the member is skipped instead; cover it with the provider's sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vandernorth vandernorth mentioned this pull request Oct 18, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant