Skip to content

fix(images): read the metadata envelope by reflection, not dynamic - #860

Open
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/bug18-image-envelope-dynamic
Open

fix(images): read the metadata envelope by reflection, not dynamic#860
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/bug18-image-envelope-dynamic

Conversation

@m4bard

@m4bard m4bard commented Aug 20, 2026

Copy link
Copy Markdown

Summary

GET /api/v1/images/{identifier} returns 500 whenever the metadata fallback runs, because the envelope it gets back is read with dynamic across an assembly boundary. This reads it by reflection instead.

Full write-up in #859.

Changes

Fixed

  • ImageCandidateLookupWorkflow reads metadata off the envelope with GetType().GetProperty(...) rather than dynamic.

GetMetadataAsync is declared Task<object?> and returns an anonymous type. The compiler emits anonymous types as internal, so the envelope is internal to Listenarr.Application while this call site is in Listenarr.Api. The runtime binder resolves members against what is visible from the call site's assembly, so it cannot see metadata and throws RuntimeBinderException reported against object. Reflection is not subject to that.

RuntimeBinderException is not in IsRecoverableImageLookupException, so it escapes the filtered catch that was written to make a malformed envelope degrade quietly, and reaches the controller catch-all. The throw is deterministic on that path; what makes it rare is the guard above, since most requests resolve a URL earlier and never enter the fallback.

Why reflection rather than something nicer

Because it is what the surrounding code already does. Ten lines below the change, the same block reads ImageUrl and Isbn off the inner object with t.GetProperty(...). LibraryMetadataRescanWorkflow.TryExtractMetadataLookupResult unwraps this exact envelope the same way and works. Of the four callers of GetMetadataAsync, this was the only one using dynamic, and the only one that throws.

The better fix is a named public record for the envelope so GetMetadataAsync stops returning object?, which would remove the reason three consumers each invented their own unwrapping. I did not do that here because it changes an interface with four call sites, and whether that signature should stay object? is a design decision rather than a bug fix. This does not foreclose it. The issue lays out both.

Testing

ImagesController_MetadataDownloadFallbackTests gains a case using an anonymous envelope, which is the shape production actually returns. Because the anonymous type is emitted internal to the test assembly and the workflow lives in Listenarr.Api, it reproduces the same accessibility relationship as production.

The existing test in that file covers this fallback but mocks a bare AudibleBookResponse, and its own comment says that is to avoid "anonymous envelope issues". That takes the is AudibleBookResponse branch and never reaches the unwrap, so the one shape that fails was the one shape not covered.

Verified as a real guard: with dynamic restored the new test fails, and it fails by never calling the downloader at all, which is the actual production symptom rather than a proxy for it.

Full suite: 3,030 passed, 0 failed, 125 skipped, against a 3,029 baseline on 03958c15.

One note on the comment

BackendArchitectureTests.ActiveProductionSourceFiles_RemainFocused caps a production source file at 500 lines, and this file is at 487 on canary. My first version of the explanatory comment took it to 503 and failed that test. The comment is deliberately short as a result and the full reasoning lives in the issue. Flagging it because it is the kind of budget worth knowing about before writing prose into a file near the limit.

GetMetadataAsync is declared Task<object?> and returns an anonymous type. The
compiler emits anonymous types as internal, so that envelope is internal to
Listenarr.Application while the call site is in Listenarr.Api. The runtime binder
resolves members against what is visible from the call site's assembly, cannot
see `metadata`, and throws RuntimeBinderException reported against `object`.

RuntimeBinderException is not in IsRecoverableImageLookupException, so it escapes
the filtered catch in this method and reaches the controller catch-all, which
returns 500. The throw is deterministic on that path, not intermittent; what makes
it rare is the guard above it, since most requests resolve an image URL earlier
and never enter the fallback.

Reflection is not subject to the binder's accessibility rules. It is also already
what this same block does ten lines further down to read ImageUrl and Isbn off the
inner object, and what LibraryMetadataRescanWorkflow does to unwrap this very
envelope. So this makes one outlier agree with its neighbours rather than
introducing an approach.

The existing fallback test returns an AudibleBookResponse directly and says in its
own comment that this is to avoid anonymous envelope issues, which means the one
shape production actually returns was the one shape never covered. The new test
uses an anonymous envelope; because it is emitted internal to the test assembly and
the workflow lives in Listenarr.Api, it reproduces the same accessibility
relationship as production.

Comment kept short deliberately: BackendArchitectureTests caps a production source
file at 500 lines and this file is at 487 on canary. The full explanation is in the
issue rather than inline.
@m4bard
m4bard requested a review from a team August 20, 2026 22:27
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