Skip to content

fix(opds): parse an empty OPDS 2.0 feed as a feed, not a publication - #7

Open
raphi011 wants to merge 1 commit into
developfrom
fix/opds-empty-feed
Open

fix(opds): parse an empty OPDS 2.0 feed as a feed, not a publication#7
raphi011 wants to merge 1 commit into
developfrom
fix/opds-empty-feed

Conversation

@raphi011

Copy link
Copy Markdown
Owner

Problem

OPDS2Parser.parse(jsonData:url:response:) decides whether a JSON document is a feed or a publication by the absence of collection arrays:

if topLevelDict["navigation"] == nil,
   topLevelDict["groups"] == nil,
   topLevelDict["publications"] == nil,
   topLevelDict["facets"] == nil {
    // Publication only
    parseData.publication = try Publication(json: jsonRoot)
} else {
    // Feed
    parseData.feed = try parse(feedURL: url, jsonDict: topLevelDict)
}

But an empty OPDS 2.0 feed (zero results — e.g. an empty search) legitimately omits every collection array. Real servers emit exactly this shape: Komga serializes its feeds with @JsonInclude(NON_EMPTY), which drops empty arrays entirely, so an empty search returns just metadata + links:

{
  "metadata": { "title": "Search: nomatch" },
  "links": [
    { "rel": "self", "href": "/opds/search?q=nomatch", "type": "application/opds+json" }
  ]
}

with Content-Type: application/opds+json.

That collection-less document is misread as a Publication. Publication(json:) then throws (it isn't a publication), the error is swallowed by catch { log(.warning, error) }, and parse returns a ParseData with neither .feed nor .publication. Downstream ReadiumOPDS consumers surface this as a generic "not a feed" error on every zero-result search — the user sees an error instead of an empty result list.

Root cause

The disambiguation ignores the response media type, which the OPDS 2.0 spec designates as the authoritative feed-vs-publication signal:

  • Feedapplication/opds+json (§1.1)
  • Publicationapplication/opds-publication+json (§5.1)

Zero-result feeds are a genuine spec gap ("publications": [] would violate the feed schema's minItems: 1 too), so servers omit the arrays — clients must tolerate collection-less feeds. Deciding "publication" from the absence of collections is the defect.

Note: the identical heuristic exists in kotlin-toolkit (OPDS2Parser.kt, it.has("navigation") || it.has("groups") || …) and has the same bug. Happy to open a matching PR there.

Fix

Follow the spec's media-type signal. Treat a document as a Publication only when it neither carries a feed collection nor declares the feed media type (application/opds+json, read from response.mimeType). An empty application/opds+json document then parses as the empty feed it is:

let declaresFeed = (response.mimeType.flatMap { MediaType($0) })?.matches(.opds2) ?? false
let hasFeedCollection = topLevelDict["navigation"] != nil
    || topLevelDict["groups"] != nil
    || topLevelDict["publications"] != nil
    || topLevelDict["facets"] != nil
if hasFeedCollection || declaresFeed {
    parseData.feed = try parse(feedURL: url, jsonDict: topLevelDict)   // incl. empty feed
} else {
    parseData.publication = try Publication(json: jsonRoot)
}

This is a minimal, behavior-preserving change: the only documents whose classification changes are collection-less ones that declare application/opds+json — i.e. exactly the empty feeds that previously failed. A real publication document (declared application/opds-publication+json, or any doc carrying an acquisition-only shape without the feed media type) still parses as a publication.

Test

Adds readium_opds2_0_test.testEmptyFeedWithOPDSMediaTypeParsesAsFeed and Tests/OPDSTests/Samples/opds_2_0_empty_feed.json (the Komga-style empty-feed shape), asserting the document parses as a feed with zero publications/navigation rather than being misread as a publication.

Reproduction server

https://mayberry.pub/opds (OPDS 2.0) — any search that returns no matches, e.g. /opds/search?q=zzznoresultsquery, reproduces the empty-feed shape.

OPDS2Parser classified any JSON document lacking navigation/groups/
publications/facets collections as an OPDS Publication. But a zero-result
feed legitimately omits every collection array — Komga's
`@JsonInclude(NON_EMPTY)` drops empty arrays entirely, leaving just
`metadata` + `links` on an empty search. Such a document was misread as a
publication; `Publication(json:)` then throws, so `parse` returned neither
a feed nor a publication and clients surfaced "not a feed" on every empty
search.

Per the OPDS 2.0 spec the media type is the authoritative feed-vs-
publication signal (`application/opds+json` vs
`application/opds-publication+json`). Treat a document as a Publication
only when it neither carries a feed collection nor declares the feed
media type, so an empty `application/opds+json` document parses as the
empty feed it is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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