Artwork cannot be picked by hand: the RemoteImages routes are two stubs and a 404 #200

Closed
opened 2026-09-12 08:51:00 +02:00 by Claude · 1 comment
Collaborator

The dashboard's artwork picker — Change image, which lists what TMDb has for
an item and lets an operator pick one — cannot work. Two of its three routes
answer empty and the third is unwritten:

// crates/ephyra-http/src/routes/absent.rs:45
// The remote metadata providers, which nothing here can reach.
.route("/Items/{itemId}/RemoteImages", get(no_remote_images))
.route("/Items/{itemId}/RemoteImages/Providers", get(no_providers))

POST /Items/{itemId}/RemoteImages/Download is not routed at all. Automatic
artwork does work — a scan fetches and files the pictures a film won — so
this is only the hand-picking half.

The comment above the stubs is out of date: M10 landed the providers, and
nothing here needs to reach anything new.

Both halves already exist in the tree

Listing. The providers answer in exactly the shape the route wants —
Vec<RemoteImage>, and RemoteImage
(crates/ephyra-core/src/item/described.rs:15) carries kind, url, width,
height, language, which is RemoteImageInfo member for member:

  • metadata/tmdb/mod.rs:131 — images (a film)
  • metadata/tmdb/tv.rs:159,180,212 — series_images, season_images,
    episode_images
  • metadata/artwork.rs:70 — thumb (a studio's, from the image repository)

Downloading. crates/ephyra-app/src/scan/run/remote.rs:816 — download
fetches a url and writes the picture where upstream keeps it, picking the
extension off the url and returning the path and the store's modified time.
That is what SaveImage does upstream.

So the work is the route layer: resolve the item, pick the right provider call
for its kind, page the result, and for the download, call the existing writer
and update the row.

What upstream answers

upstream/Jellyfin.Api/Controllers/RemoteImageController.cs:

  • GetRemoteImages (line 62) — takes type, startIndex, limit,
    providerName, includeAllLanguages; answers a RemoteImageResult with
    Images, TotalRecordCount and Providers. The DTO is already generated in
    ephyra-dto — the stub returns RemoteImageResult::default() today.
  • GetRemoteImageProviders (line 127) — the provider names for the item, no
    network.
  • DownloadRemoteImage (line 151) — requires elevation, takes type and
    imageUrl on the query string, saves, marks the item ImageUpdate, answers
    204; 404 for an unknown item.

Verifying it

D27: the network is recorded once and replayed to both servers. The recording
is crates/ephyra-app/corpus/fetched.json beside the answer key
enriched.json, cut together by cargo xtask reference gen-enriched-corpus;
--extend keeps what is recorded and asks only for what is new, which is the
mode to use here. A replay that misses hard-fails, so an unrecorded request
cannot silently reach the network in a test.

The picture-choosing rule M10 settled is the thing to not re-litigate: the
size asked for is original, and a row records what the database reported
—
picking differently is a different width and a different diff.

Open

  • Providers first. It reaches nothing and makes the picker's provider
    filter real; worth landing on its own.
  • Whether the download respects a locked image. M10 made merge take the
    set of locked fields. An operator picking a poster by hand is explicit and
    should probably override, which is what upstream does — but it is a decision,
    not a given.
The dashboard's artwork picker — *Change image*, which lists what TMDb has for an item and lets an operator pick one — cannot work. Two of its three routes answer empty and the third is unwritten: ```rust // crates/ephyra-http/src/routes/absent.rs:45 // The remote metadata providers, which nothing here can reach. .route("/Items/{itemId}/RemoteImages", get(no_remote_images)) .route("/Items/{itemId}/RemoteImages/Providers", get(no_providers)) ``` `POST /Items/{itemId}/RemoteImages/Download` is not routed at all. Automatic artwork **does** work — a scan fetches and files the pictures a film won — so this is only the hand-picking half. The comment above the stubs is out of date: M10 landed the providers, and nothing here needs to reach anything new. ## Both halves already exist in the tree **Listing.** The providers answer in exactly the shape the route wants — `Vec<RemoteImage>`, and `RemoteImage` (`crates/ephyra-core/src/item/described.rs:15`) carries `kind`, `url`, `width`, `height`, `language`, which is `RemoteImageInfo` member for member: - `metadata/tmdb/mod.rs:131` — `images` (a film) - `metadata/tmdb/tv.rs:159,180,212` — `series_images`, `season_images`, `episode_images` - `metadata/artwork.rs:70` — `thumb` (a studio's, from the image repository) **Downloading.** `crates/ephyra-app/src/scan/run/remote.rs:816` — `download` fetches a url and writes the picture where upstream keeps it, picking the extension off the url and returning the path and the store's modified time. That is what `SaveImage` does upstream. So the work is the route layer: resolve the item, pick the right provider call for its kind, page the result, and for the download, call the existing writer and update the row. ## What upstream answers `upstream/Jellyfin.Api/Controllers/RemoteImageController.cs`: - `GetRemoteImages` (line 62) — takes `type`, `startIndex`, `limit`, `providerName`, `includeAllLanguages`; answers a `RemoteImageResult` with `Images`, `TotalRecordCount` and `Providers`. The DTO is already generated in `ephyra-dto` — the stub returns `RemoteImageResult::default()` today. - `GetRemoteImageProviders` (line 127) — the provider names for the item, no network. - `DownloadRemoteImage` (line 151) — **requires elevation**, takes `type` and `imageUrl` on the query string, saves, marks the item `ImageUpdate`, answers `204`; `404` for an unknown item. ## Verifying it D27: the network is recorded once and replayed to both servers. The recording is `crates/ephyra-app/corpus/fetched.json` beside the answer key `enriched.json`, cut together by `cargo xtask reference gen-enriched-corpus`; `--extend` keeps what is recorded and asks only for what is new, which is the mode to use here. A replay that misses hard-fails, so an unrecorded request cannot silently reach the network in a test. The picture-choosing rule M10 settled is the thing to not re-litigate: *the size asked for is `original`, and a row records what the database reported* — picking differently is a different width and a different diff. ## Open - **`Providers` first.** It reaches nothing and makes the picker's provider filter real; worth landing on its own. - **Whether the download respects a locked image.** M10 made `merge` take the set of locked fields. An operator picking a poster by hand is explicit and should probably override, which is what upstream does — but it is a decision, not a given.
Author
Collaborator

Done in 5e6bb607, with the review's findings in 0087bbea.

The picker is crates/ephyra-http/src/routes/library/remote_images.rs; the
fan-out a refresh already ran is now one function both callers reach, behind
RemoteArtwork — the composition root handing a route the same databases and
the same keys a scan gets.

Measured against a pinned upstream on the enriched fixture, both live. The
listings agree exactly:

Alien         ours 321 rows   upstream 321   same order, same fields
Blade Runner  ours 270        upstream 270   same order, same fields

and /RemoteImages/Providers agrees for all 156 items the fixture holds —
every kind, including the ones no scenario reaches.

Three things upstream disagreed with the first attempt about, each found by
that diff rather than by reading the C#:

  • The order is the refresh's own. OrderByLanguageDescending is not a
    language sort — it is language, then rating to a tenth, then votes, which is
    what a scan already ranked by. An Order enum threaded through six provider
    methods came back out.
  • A backdrop with words on it is a thumbnail, decided per picture. Splitting
    the list into two passes reordered every picture that tied with another.
  • No size. Upstream records one only where the database was asked for the
    original, and nothing configures it to be.

What the review found

  • A walk illustrated a music folder. Sharing the fan-out widened the kinds
    fetch_pictures answers for, so an album carrying a release-group id was
    asked twice per scan and filed two posters under a forced image refresh. The
    first test written for it could not fail — the fake host answered every
    request with the same bytes — so the fixture now answers the music database
    with an album, and the test fails without the guard.
  • A chapter could be filed by hand: the guard stayed behind on the upload path
    when the write was extracted.
  • A trailer and a studio were named a database and offered nothing by it.
    The two routes read the item's kind through different functions and had come
    apart. Kind::Studio was also missing from the list of kinds a stored type
    decodes to.
  • A picture was named from its address rather than from what the host said it
    is, so a png behind a query was filed as a jpg and an error page would
    have been filed as a poster.

Driven through the web client

Opening Edit images → search on a film lists the posters with their database,
language and rating; the Source filter narrows to one database; picking one
files it and the dialog closes on the item's new poster.

One trap worth knowing, and it is upstream's: the pinned bundle calls
loading.show() and then .then(...) with no .catch, so a download that
fails for any reason leaves a spinner that never stops.

Done in `5e6bb607`, with the review's findings in `0087bbea`. The picker is `crates/ephyra-http/src/routes/library/remote_images.rs`; the fan-out a refresh already ran is now one function both callers reach, behind `RemoteArtwork` — the composition root handing a route the same databases and the same keys a scan gets. **Measured against a pinned upstream on the enriched fixture, both live.** The listings agree exactly: ``` Alien ours 321 rows upstream 321 same order, same fields Blade Runner ours 270 upstream 270 same order, same fields ``` and `/RemoteImages/Providers` agrees for all 156 items the fixture holds — every kind, including the ones no scenario reaches. Three things upstream disagreed with the first attempt about, each found by that diff rather than by reading the C#: - **The order is the refresh's own.** `OrderByLanguageDescending` is not a language sort — it is language, then rating to a tenth, then votes, which is what a scan already ranked by. An `Order` enum threaded through six provider methods came back out. - **A backdrop with words on it is a thumbnail, decided per picture.** Splitting the list into two passes reordered every picture that tied with another. - **No size.** Upstream records one only where the database was asked for the original, and nothing configures it to be. ### What the review found - **A walk illustrated a music folder.** Sharing the fan-out widened the kinds `fetch_pictures` answers for, so an album carrying a release-group id was asked twice per scan and filed two posters under a forced image refresh. The first test written for it could not fail — the fake host answered every request with the same bytes — so the fixture now answers the music database with an album, and the test fails without the guard. - A chapter could be filed by hand: the guard stayed behind on the upload path when the write was extracted. - **A trailer and a studio were named a database and offered nothing by it.** The two routes read the item's kind through different functions and had come apart. `Kind::Studio` was also missing from the list of kinds a stored type decodes to. - A picture was named from its address rather than from what the host said it is, so a `png` behind a query was filed as a `jpg` and an error page would have been filed as a poster. ### Driven through the web client Opening *Edit images → search* on a film lists the posters with their database, language and rating; the Source filter narrows to one database; picking one files it and the dialog closes on the item's new poster. One trap worth knowing, and it is upstream's: the pinned bundle calls `loading.show()` and then `.then(...)` with no `.catch`, so a download that fails for any reason leaves a spinner that never stops.
Sign in to join this conversation.
No description provided.