Skip to content

fix(metadata): validate direct provider IDs by year with cross-provider fallback#2643

Merged
enoch85 merged 5 commits into
developmentfrom
fix/metadata-id-validation
Apr 9, 2026
Merged

fix(metadata): validate direct provider IDs by year with cross-provider fallback#2643
enoch85 merged 5 commits into
developmentfrom
fix/metadata-id-validation

Conversation

@enoch85

@enoch85 enoch85 commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Swaps the title-based check for direct provider IDs with an ID-first, year-as-sanity-check approach.

When an item has a direct TMDB/TVDB/IMDB id, the matching provider is asked for the entry's year. If it agrees with the media server, the id is accepted. If it disagrees, the next configured provider gets a chance to vouch — and if that secondary doesn't have an id on the item itself, Maintainerr bridges to it through an existing external reference (e.g. IMDB → TVDB). If nothing confirms the year, the direct ids are dropped so rules never run against the wrong entry.

Stale ids that a provider has since redirected (e.g. tmdb: 111222) are still rewritten to the canonical value, same as before.

Why

The old title check was too strict for normal libraries. Cosmetic drift — localization, Roman vs Arabic numerals, word-vs-digit, edition suffixes, punctuation — was enough to reject correct ids and silently drop items from rule evaluation.

Titles are also the wrong signal to lean on: they're too permissive when identical (re-scan mixups slip through) and too strict when they're not. Year is a much stronger discriminator — it's a single integer, immune to all the cosmetic drift, and genuine disagreement is a real red flag worth surfacing.

Fail-closed on unresolved year disagreement is the safer default for a deletion tool. Skipping a deletion is recoverable; deleting the wrong thing isn't.

Known gap: a wrong id that coincidentally shares the correct entry's year still slips through. Catching that would need an external title search the provider interface doesn't expose. Same as 3.3.0.

Fixes

@enoch85

enoch85 commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator Author

For some context:

Aspect 2.25.0 (TmdbIdService) This PR (MetadataService.validateDirectIds)
Media server support Plex only Plex + Jellyfin (shared abstraction)
Metadata providers TMDB only (TVDB/IMDB were just bridge keys) TMDB + TVDB as first-class providers, user-configurable primary
ID read order TMDB GUID → TVDB GUID → IMDB GUID All direct ids extracted up-front, walked in user's preferred provider order
Validation of the direct id None — trust whatever Plex tagged Call the owning provider's getDetails, sanity-check the year
Title check None None (deliberately — titles proved too brittle)
Year check None Yes — against the media server's year, as the only sanity signal
Wrong id → wrong year Silently accepted Rejected (fail-closed), warning logged
Wrong id → coincident year Silently accepted Silently accepted (documented known gap, same as 2.25.0)
Stale/redirected id (e.g. TMDB 111 → canonical 222) Not handled — 111 is returned Corrected via provider's returned externalIds
Cross-provider fallback N/A (single provider) Secondary provider gets a chance to vouch on disagreement; bridged via IMDB → TVDB etc. when secondary has no direct id on the item
Return shape { type, id } — just TMDB ResolvedMediaIds bag — TMDB, TVDB, IMDB, type
Downstream Seerr/Radarr/Sonarr lookups Only worked via TMDB id Work from any id the validated set exposes
Behavior when no id resolvable Returns { type, id: undefined } Returns undefined, rules skip the item

@enoch85

enoch85 commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator Author

/release-pr

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Released to maintainerr/maintainerr:pr-2643 🚀

Comment thread apps/server/src/modules/metadata/metadata.service.ts
Comment thread apps/server/src/modules/metadata/metadata.service.ts Outdated
Comment thread apps/server/src/modules/metadata/metadata.service.ts Outdated
Comment thread apps/server/src/modules/metadata/metadata.service.ts Outdated
@enoch85 enoch85 requested a review from Simon-Eklundh April 9, 2026 19:40
@enoch85 enoch85 added this to the 3.5.0 milestone Apr 9, 2026
@enoch85 enoch85 merged commit 1933af8 into development Apr 9, 2026
13 checks passed
@enoch85 enoch85 deleted the fix/metadata-id-validation branch April 9, 2026 20:03
maintainerr-automation Bot added a commit that referenced this pull request Apr 9, 2026
* build(deps-dev): bump @types/node from 22.19.15 to 22.19.17 (#2630)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.15 to 22.19.17.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.19.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump postcss from 8.5.8 to 8.5.9 (#2629)

Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.9.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](postcss/postcss@8.5.8...8.5.9)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump @typescript-eslint/parser from 8.57.2 to 8.58.1 (#2631)

Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.57.2 to 8.58.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.58.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.58.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump vitest from 4.1.2 to 4.1.3 (#2632)

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.3.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.3/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(jellyfin): clear stale collection link when media server collection is deleted

Re-throw 400/404 errors from Jellyfin getCollectionChildren instead of
swallowing them, so callers can detect when a collection no longer exists.

In getCollectionMediaMetadata, verify the collection is truly gone via
getCollection before clearing the mediaServerId link. This double-check
prevents false positives from resetting collection counters. If the
verification call itself fails transiently, the link is preserved.

* refactor(metadata): generic metadata-lookup utility (#2633)

* fix(jellyfin): exclude virtual episodes from child queries (#2624)

Virtual episodes in Jellyfin are placeholders for unaired content.
Including them caused ended shows to appear as having episodes,
preventing proper cleanup.

Closes #2558

* feat: clean up empty Sonarr shows after season actions (#2618)

* fix(server): import SeerrApiModule into ActionsModule

* fix(jellyfin): lower collection mutation batch size

* build(deps): bump nodemailer from 8.0.4 to 8.0.5 (#2635)

Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.4 to 8.0.5.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](nodemailer/nodemailer@v8.0.4...v8.0.5)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump the nestjs group with 3 updates (#2639)

Bumps the nestjs group with 3 updates: [@nestjs/serve-static](https://github.com/nestjs/serve-static), [@nestjs/swagger](https://github.com/nestjs/swagger) and [@nestjs/cli](https://github.com/nestjs/nest-cli).


Updates `@nestjs/serve-static` from 5.0.4 to 5.0.5
- [Release notes](https://github.com/nestjs/serve-static/releases)
- [Commits](nestjs/serve-static@5.0.4...5.0.5)

Updates `@nestjs/swagger` from 11.2.6 to 11.2.7
- [Release notes](https://github.com/nestjs/swagger/releases)
- [Commits](nestjs/swagger@11.2.6...11.2.7)

Updates `@nestjs/cli` from 11.0.18 to 11.0.19
- [Release notes](https://github.com/nestjs/nest-cli/releases)
- [Commits](nestjs/nest-cli@11.0.18...11.0.19)

---
updated-dependencies:
- dependency-name: "@nestjs/serve-static"
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nestjs
- dependency-name: "@nestjs/swagger"
  dependency-version: 11.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nestjs
- dependency-name: "@nestjs/cli"
  dependency-version: 11.0.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: nestjs
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump the react group with 2 updates (#2640)

Bumps the react group with 2 updates: [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump vitest from 4.1.3 to 4.1.4 (#2641)

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.3 to 4.1.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): bump axios from 1.14.0 to 1.15.0 (#2642)

Bumps [axios](https://github.com/axios/axios) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](axios/axios@v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(collections): make Delete Latest send a real sort param (#2634)

Delete Latest was defined as the empty-string "no sort" fallback with
sortParams: undefined. In CollectionMediaPage.fetchCollectionMediaPage,
passing undefined as the override triggered the `= sortParams` default
parameter fallback, so the request sent for Delete Latest was identical
to Delete Soonest.

Introduce a dedicated deleteSoonest.desc option and filter the empty
fallback out when delete sorts are present. Both options still hit the
fast addDate branch in getCollectionMediaWithServerDataAndPaging, so
performance is unchanged.

Fixes #2634

* fix(collections): auto-load next page when viewport already filled

The infinite-scroll hook only fetched more pages in response to a
scroll event. On large viewports that could display all 30 initial
items without producing a scrollbar, no scroll event ever fired and
the list got stuck at 30 until the window was resized.

Re-check isNearBottom() after each append and also listen for resize,
so additional pages cascade in until the document is actually
scrollable (or totalSize is reached).

Fixes #2637

* fix: carry over valid PR 2534 hardening fixes (#2617)

* fix(metadata): validate direct provider IDs by year with cross-provider fallback (#2643)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: maintainerr-automation[bot] <261505141+maintainerr-automation[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: enoch85 <mailto@danielhansson.nu>
@maintainerr-automation

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.5.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

maintainerr-automation Bot added a commit that referenced this pull request May 28, 2026
* chore(metadata): log resolved id and title on year-drift accept

Bumps the silent ±1 year-drift accept from debug to info and includes the
resolved provider id and title in the message, so a human reading the log
can verify the match (the underlying id-primary / year-sanity design from
#2643 still applies — this only adds visibility).

* chore(deps): drop vulnerable tmp@0.0.33 by deduping @inquirer/editor

Resolves GHSA path-traversal advisory #213 (tmp < 0.2.6). The vulnerable
copy was pulled transitively via @nestjs/cli (dev) ->
@angular-devkit/schematics-cli@19.2.24 -> @inquirer/prompts@7.3.2 ->
@inquirer/editor@4.2.7 -> external-editor@3.1.0 -> tmp@0.0.33.

Deduping @inquirer/editor to 4.2.23 (already in the tree via
@inquirer/prompts@7.10.1) swaps to @inquirer/external-editor, which has
no tmp dependency. Removes external-editor, tmp, os-tmpdir, chardet,
iconv-lite, and the old @inquirer/editor from the lockfile.

---------

Co-authored-by: enoch85 <mailto@danielhansson.nu>
fragglexarmy pushed a commit to fragglexarmy/Maintainerr that referenced this pull request May 29, 2026
Bumps the silent ±1 year-drift accept from debug to info and includes the
resolved provider id and title in the message, so a human reading the log
can verify the match (the underlying id-primary / year-sanity design from
Maintainerr#2643 still applies — this only adds visibility).
enoch85 added a commit that referenced this pull request Jun 3, 2026
…as alternate fallback

When TMDB's external_ids reports a different (sometimes wrong or dead) id
for another provider, applyIdCorrections used to overwrite the media-server
id with it — issue #3010 verified TMDB returning a 404 TVDB id for a show
the media server had correct, breaking the Sonarr lookup.

Now the media-server primary stays authoritative; the cross-reference id
is recorded as a per-(ids,provider) alternate in a WeakMap side channel.
buildLookupCandidates emits primary first, alternate second; the
Sonarr/Radarr/Seerr getters and action handlers all reach the alternate
via their existing candidate flows. findMetadataLookupMatch advances past
a confirmed-miss null (preserved as a remembered fallback) so the new
candidate iteration actually reaches the alternate when the primary is
not in Sonarr.

The #2643 redirect intent is preserved: stale primaries still work,
just via the fallback instead of an overwrite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Certain media not being deleted with 3.4.1, correctly deleting in 3.3.0 After latest dev upgrade, Maintainerr reading metadata incorrectly

2 participants