The Identify dialog has nothing to call: ItemLookup is one stubbed route of eleven #199

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

ItemLookup is 1 of 11 answered in ENDPOINTS.md, and the one is a stub. The
web UI's Identify dialog — the one that fixes a film the scan matched
wrongly — has nothing to call, so a mismatched row can only be corrected by
renaming the file and rescanning.

Unanswered: POST /Items/RemoteSearch/{Movie,Series,BoxSet,MusicAlbum,MusicArtist,MusicVideo,Person,Trailer,Book}
and POST /Items/RemoteSearch/Apply/{itemId}.

Answered, emptily:

// crates/ephyra-http/src/routes/absent.rs:48
.route("/Items/{itemId}/ExternalIdInfos", get(no_external_ids))

ExternalIdInfos is what populates the dialog's or enter an id yourself
fields, so even the manual half is blank.

The fetchers are here; the routes are not

crates/ephyra-app/src/metadata/ already reaches every database the dialog
would search, and one of them already searches:

// crates/ephyra-app/src/metadata/tmdb/tv.rs:261
pub async fn identify_series(&self, lookup: &Lookup, name: &str, year: Option<i32>)
    -> Result<Option<String>, FetchError>

It takes the first result, as upstream takes it when a scan identifies a
series. That is the asymmetry to close: a scan wants one answer, and the
Identify dialog wants the list the first was taken from. So the work is not a
new fetcher — it is lifting the search to return candidates
(RemoteSearchResult: name, year, the poster url, the provider ids) and
spelling a route per item kind over it.

Providers in the tree: metadata/tmdb/{mod,tv,person,box_set}.rs,
metadata/omdb.rs, metadata/musicbrainz.rs, metadata/audiodb.rs. Between
them they cover every RemoteSearch kind except Book.

Apply is a refresh, not a write

upstream/Jellyfin.Api/Controllers/ItemLookupController.cs:245 —
ApplySearchCriteria sets the provider ids on the row explicitly (a refresh
will not erase them), then calls RefreshFullItem with ReplaceAllMetadata,
RemoveOldMetadata, and ReplaceAllImages off the query string. 204 on
success, 404 for an unknown item, and it requires elevation.

Our equivalent of that refresh is the provider chain in
crates/ephyra-app/src/metadata/mod.rs (merge / apply) driven by
scan::run's refresh path, and the lock rule already there matters: M10 made
merge take the set of locked fields, so an Apply has to decide whether the
operator's explicit identification overrides a lock. Upstream's does.

Verifying it

D27 is the constraint: a fetcher reaches a network and a network is not
something two servers can be compared over. The recording is
crates/ephyra-app/corpus/fetched.json, the answer key is enriched.json, and
they are cut together by
cargo xtask reference gen-enriched-corpus [--extend] — --extend keeps every
answer already recorded and asks only for the new ones, which is what a search
route needs. A replay that misses hard-fails rather than falling through to the
network, so an unrecorded search is a failing test and not a silent live call.

Open

  • Whether ExternalIdInfos comes first. It is the cheap half — a static
    description of the providers a kind has, with no network at all — and it
    makes the manual id entry in the dialog work on its own.
  • Book. Nothing here resolves books at all (see the library-kinds issue),
    so that route has no provider to call and should probably stay unwritten
    rather than answer an empty list.
`ItemLookup` is 1 of 11 answered in `ENDPOINTS.md`, and the one is a stub. The web UI's **Identify** dialog — the one that fixes a film the scan matched wrongly — has nothing to call, so a mismatched row can only be corrected by renaming the file and rescanning. Unanswered: `POST /Items/RemoteSearch/{Movie,Series,BoxSet,MusicAlbum,MusicArtist,MusicVideo,Person,Trailer,Book}` and `POST /Items/RemoteSearch/Apply/{itemId}`. Answered, emptily: ```rust // crates/ephyra-http/src/routes/absent.rs:48 .route("/Items/{itemId}/ExternalIdInfos", get(no_external_ids)) ``` `ExternalIdInfos` is what populates the dialog's *or enter an id yourself* fields, so even the manual half is blank. ## The fetchers are here; the routes are not `crates/ephyra-app/src/metadata/` already reaches every database the dialog would search, and one of them already searches: ```rust // crates/ephyra-app/src/metadata/tmdb/tv.rs:261 pub async fn identify_series(&self, lookup: &Lookup, name: &str, year: Option<i32>) -> Result<Option<String>, FetchError> ``` It **takes the first result, as upstream takes it** when a scan identifies a series. That is the asymmetry to close: a scan wants one answer, and the Identify dialog wants the list the first was taken from. So the work is not a new fetcher — it is lifting the search to return candidates (`RemoteSearchResult`: name, year, the poster url, the provider ids) and spelling a route per item kind over it. Providers in the tree: `metadata/tmdb/{mod,tv,person,box_set}.rs`, `metadata/omdb.rs`, `metadata/musicbrainz.rs`, `metadata/audiodb.rs`. Between them they cover every `RemoteSearch` kind except `Book`. ## Apply is a refresh, not a write `upstream/Jellyfin.Api/Controllers/ItemLookupController.cs:245` — `ApplySearchCriteria` sets the provider ids on the row explicitly (a refresh will not erase them), then calls `RefreshFullItem` with `ReplaceAllMetadata`, `RemoveOldMetadata`, and `ReplaceAllImages` off the query string. `204` on success, `404` for an unknown item, and it requires elevation. Our equivalent of that refresh is the provider chain in `crates/ephyra-app/src/metadata/mod.rs` (`merge` / `apply`) driven by `scan::run`'s refresh path, and the lock rule already there matters: M10 made `merge` take the set of locked fields, so an Apply has to decide whether the operator's explicit identification overrides a lock. Upstream's does. ## Verifying it D27 is the constraint: a fetcher reaches a network and a network is not something two servers can be compared over. The recording is `crates/ephyra-app/corpus/fetched.json`, the answer key is `enriched.json`, and they are cut together by `cargo xtask reference gen-enriched-corpus [--extend]` — `--extend` keeps every answer already recorded and asks only for the new ones, which is what a search route needs. A replay that misses hard-fails rather than falling through to the network, so an unrecorded search is a failing test and not a silent live call. ## Open - **Whether `ExternalIdInfos` comes first.** It is the cheap half — a static description of the providers a kind has, with no network at all — and it makes the manual id entry in the dialog work on its own. - **Book.** Nothing here resolves books at all (see the library-kinds issue), so that route has no provider to call and should probably stay unwritten rather than answer an empty list.
Author
Collaborator

Done in f6ddc801 and c4bb3040.

The half that reaches no network

/Items/{id}/ExternalIdInfos answered an empty array, and so did the edit
dialog's own copy of the same list — so the dialog drew no id boxes at all and
an operator could not correct a row by hand. Both now answer from one table,
and the route resolves its item and takes an administrator, neither of which
the stub did.

The table is upstream's twenty-five registered ids in upstream's
registration order
, which is assembly scan order rather than anything the C#
states. It was read off a running upstream: MusicBrainz before TheAudioDb,
and Tmdb before TmdbCollection, were both the wrong way round first.
Measured over 159 items in thirteen kinds, every one agreeing — three of those
kinds are in no fixture and were seeded into both databases to be asked about.

The half that does

Every RemoteSearch route upstream declares. TMDb searches for a film, a
series and a person; OMDb for a film, a trailer and a series. The chain is
upstream's own — the id the item carries, then the id another database
resolves, then a search for the name — and identify_series keeps its
shortcut, so a refresh holding an id still asks for nothing. A kind no
database here searches answers an empty list rather than a 404, which is
what upstream answers where none of its providers supports one.

Apply writes the chosen ids through a new set_providers before the
refresh rather than handing them to it: a pass reads what a row is filed
under, so an id not yet on the row is one nothing can look up. The refresh
keeps none of what was there, which is what corrects a row rather than merging
into it.

Twelve of thirteen searches agree with the pin field for field. Three things
it disagreed about first, each found by diffing rather than by reading:

  • Upstream's two paths disagree about a film's year. A refresh stores the
    release date as written; a search calls ToUniversalTime() on a date with
    no zone in it, which .NET reads as local — so a film released on the first
    of a year is offered under the year before it and the row a refresh then
    writes says the other. Its two providers then disagree with each other: a
    film's year comes off the converted stamp and a series' off the raw date.
    Matched rather than corrected; on a server running UTC they coincide.
  • A written-out release date and an N/A poster are kept as that database
    spells them.
  • TMDb numbers the TVDb id and writes the IMDb one.

Not written, named rather than implied

The MusicBrainz album and artist searches: upstream's query is not the one
the metadata path already makes (artist:"X"c, quotes stripped from the
name), so it is a port of its own rather than exposing what exists — and that
database's one-request-a-second limit makes a differential slow enough that
guessing would be worse than waiting. BoxSet, MusicVideo and Book have
routes and no database here that answers them.

Done in `f6ddc801` and `c4bb3040`. ### The half that reaches no network `/Items/{id}/ExternalIdInfos` answered an empty array, and so did the edit dialog's own copy of the same list — so the dialog drew no id boxes at all and an operator could not correct a row by hand. Both now answer from one table, and the route resolves its item and takes an administrator, neither of which the stub did. The table is upstream's twenty-five registered ids in **upstream's registration order**, which is assembly scan order rather than anything the C# states. It was read off a running upstream: `MusicBrainz` before `TheAudioDb`, and `Tmdb` before `TmdbCollection`, were both the wrong way round first. Measured over 159 items in thirteen kinds, every one agreeing — three of those kinds are in no fixture and were seeded into both databases to be asked about. ### The half that does Every `RemoteSearch` route upstream declares. `TMDb` searches for a film, a series and a person; `OMDb` for a film, a trailer and a series. The chain is upstream's own — the id the item carries, then the id another database resolves, then a search for the name — and `identify_series` keeps its shortcut, so a refresh holding an id still asks for nothing. A kind no database here searches answers an empty list rather than a `404`, which is what upstream answers where none of its providers supports one. `Apply` writes the chosen ids through a new `set_providers` **before** the refresh rather than handing them to it: a pass reads what a row is filed under, so an id not yet on the row is one nothing can look up. The refresh keeps none of what was there, which is what corrects a row rather than merging into it. Twelve of thirteen searches agree with the pin field for field. Three things it disagreed about first, each found by diffing rather than by reading: - **Upstream's two paths disagree about a film's year.** A refresh stores the release date as written; a search calls `ToUniversalTime()` on a date with no zone in it, which .NET reads as local — so a film released on the first of a year is *offered* under the year before it and the row a refresh then writes says the other. Its two providers then disagree with each other: a film's year comes off the converted stamp and a series' off the raw date. Matched rather than corrected; on a server running UTC they coincide. - A written-out release date and an `N/A` poster are kept as that database spells them. - `TMDb` numbers the `TVDb` id and writes the `IMDb` one. ### Not written, named rather than implied The `MusicBrainz` album and artist searches: upstream's query is not the one the metadata path already makes (`artist:"X"c`, quotes stripped from the name), so it is a port of its own rather than exposing what exists — and that database's one-request-a-second limit makes a differential slow enough that guessing would be worse than waiting. `BoxSet`, `MusicVideo` and `Book` have routes and no database here that answers them.
Sign in to join this conversation.
No description provided.