Skip to content

Metadata provider abstraction layer with TVDB support#2406

Closed
enoch85 wants to merge 203 commits into
Maintainerr:developmentfrom
enoch85:feat/metadata-settings
Closed

Metadata provider abstraction layer with TVDB support#2406
enoch85 wants to merge 203 commits into
Maintainerr:developmentfrom
enoch85:feat/metadata-settings

Conversation

@enoch85

@enoch85 enoch85 commented Feb 23, 2026

Copy link
Copy Markdown
Collaborator

Adds a fully provider-agnostic metadata layer that centralizes all external ID resolution, image fetching, and content details behind a single IMetadataProvider interface. Adding a new metadata provider requires zero changes to MetadataService — implement the interface, register it, and it slots into the preference/fallback chain automatically.


Provider-agnostic architecture

Each provider (TMDB, TVDB, or any future addition) implements IMetadataProvider:

  • isAvailable() — is this provider configured and ready?
  • extractId() / assignId() — read/write the provider's own ID from a shared ID bag
  • getDetails() / getPosterUrl() / getBackdropUrl() / getPersonDetails() — normalised data access
  • findByExternalId() — cross-provider ID search (e.g. look up a TVDB entry by IMDB ID)

MetadataService never references TMDB or TVDB by name. It receives all registered providers via injection, orders them by the user's preference setting, and iterates with automatic fallback.

Example flow — Sonarr action handler needs IDs for a Plex item

  1. Calls metadataService.resolveIds(plexRatingKey)
  2. MetadataService fetches the item from Plex/Jellyfin
  3. Walks up the hierarchy (episode → season → show)
  4. Extracts any IDs the media server already knows (e.g. Plex provides a TMDB ID but no TVDB ID)
  5. Fills in the missing IDs (see resolution pipeline below)
  6. Returns { tmdbId, tvdbId, imdbId, type }

The handler never needs to know which provider was used.

                     ┌──────────────────────────────────────────────────┐
                     │              MetadataService                     │
                     │                                                  │
                     │  resolveIds()            getDetails()            │
                     │  resolveAllSeriesIds()    getPersonDetails()     │
                     │  resolveAllMovieIds()     getPosterUrl()         │
                     │                           getBackdropUrl()       │
                     │                                                  │
                     │  ┌──────────────────────────────────────────┐   │
                     │  │  Preference Engine                       │   │
                     │  │                                          │   │
                     │  │  getOrderedProviders()                   │   │
                     │  │    → sorts by user preference setting    │   │
                     │  │    → filters out unavailable providers   │   │
                     │  │                                          │   │
                     │  │  withProviderFallback()                  │   │
                     │  │    → tries preferred provider first      │   │
                     │  │    → falls back to next on failure       │   │
                     │  │    → skips providers missing the needed  │   │
                     │  │      ID (extractId check)                │   │
                     │  └──────────────────────────────────────────┘   │
                     │                                                  │
                     │  ┌──────────────────────────────────────────┐   │
                     │  │  ID Resolution Pipeline                  │   │
                     │  │                                          │   │
                     │  │  1. Use whatever IDs the media server    │   │
                     │  │     already provides (tmdb / tvdb / imdb)│   │
                     │  │              │                            │   │
                     │  │              ▼                            │   │
                     │  │  2. Ask a provider for full details —    │   │
                     │  │     the response often includes the      │   │
                     │  │     other provider's ID for free         │   │
                     │  │     (e.g. TMDB details contain tvdb_id)  │   │
                     │  │              │                            │   │
                     │  │              ▼  (IDs still missing?)     │   │
                     │  │  3. Search other providers by IMDB ID    │   │
                     │  │     to find the remaining IDs            │   │
                     │  └──────────────────────────────────────────┘   │
                     └────────────────────┬────────────────────────────┘
                                          │
                 ┌────────────────────────┼─────────────────────────┐
                 │                        │                         │
                 ▼                        ▼                         ▼
       ┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
       │  TmdbMetadata    │    │  TvdbMetadata     │    │  Future Metadata │
       │    Provider      │    │    Provider       │    │    Provider      │
       │                  │    │                   │    │                  │
       │  name: "TMDB"   │    │  name: "TVDB"    │    │  name: "???"    │
       │  always available│    │  if key set      │    │                  │
       ├──────────────────┤    ├──────────────────┤    ├──────────────────┤
       │    implements    │    │    implements     │    │    implements    │
       │IMetadataProvider │    │IMetadataProvider  │    │IMetadataProvider │
       └────────┬─────────┘    └────────┬─────────┘    └──────────────────┘
                │                       │
                ▼                       ▼
       ┌──────────────────┐    ┌──────────────────┐
       │  TmdbApiService  │    │  TvdbApiService   │
       │                  │    │                   │
       │  custom key or   │    │  bearer token     │
       │  built-in default│    │  v4 API           │
       │                  │    │  auto-reauth on   │
       │                  │    │  settings change   │
       └──────────────────┘    └──────────────────┘

Adding a new provider

  1. Create the API service in modules/api/xxx-api/
  2. Create a provider class implementing IMetadataProvider
  3. In MetadataModule: import the API module, add it to the MetadataProviders factory array
  4. Add one enum value to MetadataProviderPreference in @maintainerr/contracts

The preference selector, fallback chain, and ID resolution all pick it up automatically.


What changed

MetadataService — single entry point for all metadata operations

  • resolveIds() / resolveAllSeriesIds() / resolveAllMovieIds() — resolve media server items to external IDs with cross-provider gap filling
  • getDetails() / getPersonDetails() / getPosterUrl() / getBackdropUrl() — fetch content details and images with ordered provider fallback
  • Provider preference (TMDB_PRIMARY | TVDB_PRIMARY) controls resolution order and image source
  • Hot-reloads on Settings_Updated events — no restart needed

TVDB v4 API integration (TvdbApiService)

  • Bearer token auth, series/movie lookup, remote ID search, artwork retrieval
  • Re-authenticates automatically on settings change

Custom TMDB API key support

  • TmdbApiService now accepts user-provided API keys; falls back to built-in default

MetadataController — new controller at api/metadata

  • Replaces TmdbApiController (route changed from api/moviedbapi/metadata)
  • Backdrop + poster endpoints return full URLs with provider fallback
  • /person/:id endpoint removed — person details now served through MetadataService.getPersonDetails() with provider fallback
  • /movie/imdb/:id endpoint removed — IMDB lookups now handled internally by the ID resolution pipeline

All consumers updated

  • Action handlers (Radarr/Sonarr) → MetadataService.resolveIds()
  • Rule getters (Radarr/Sonarr) → resolveAllMovieIds() / resolveAllSeriesIds()
  • Collections service → MetadataService for details + images
  • MediaIdFinder eliminated

Frontend — new Metadata settings page

  • TMDB / TVDB sections: API key with test + save/delete
  • Provider preference selector (TMDB Primary / TVDB Primary)
  • React Hook Form + Zod validation

Migration: AddMetadataSupport — adds tmdb_api_key, tvdb_api_key, metadata_provider_preference columns (+ tvdbId on collection_media)

Module encapsulation

  • TmdbApiModule / TvdbApiModule removed from AppModule root imports
  • Only MetadataModule and SettingsModule import them explicitly

Closes #2400

enoch85 and others added 30 commits January 31, 2026 00:24
* feat: Add Jellyfin support with abstraction layer

- Add MediaServerService abstraction for Plex/Jellyfin
- Implement JellyfinService with full API integration
- Update rules engine to support multiple media servers
- Add Jellyfin settings UI and configuration
- Refactor collections to use MediaServerService
- Add comprehensive type safety improvements
- Update database schema for media server support

* snc against this branch instead

* feat(api): add deprecated /api/plex legacy wrapper for backward compatibility

- Add PlexApiLegacyController with all 19 original endpoints
- All endpoints delegate to MediaServerFactory abstraction
- Add deprecation headers (X-Deprecated, Deprecation, Link)
- Uses MediaServerSetupGuard (works with both Plex and Jellyfin)
- Maps old route patterns to new abstraction interface
- Single file design for easy removal when legacy support ends

To remove legacy support: delete plex-api-legacy.controller.ts and
remove PlexApiLegacyController from plex-api.module.ts

* chore: merge main branch changes

- Use leftJoinAndSelect for rules (allows groups without rules)
- Delete unused CollectionDetail/index.tsx
- Use useRuleGroupForCollection hook instead of manual fetch
- Conditionally show Test Media button only when useRules=true
- Simplify addCollectionToDB and RemoveCollectionFromDB methods

* fix: update SchemaSync migration to use mediaServerId instead of plexId

The SchemaSync migration from main referenced plexId (integer) but our
JellyfinSupport migration already renamed this to mediaServerId (varchar).
Updated both UP and DOWN methods to use the correct column name and type.

* regenerated with typeorm

* fix: standardize quotation marks and formatting in SchemaSync migration

* generate clean typeorm

* fix(ui): filter out empty/invalid rules to prevent crash

When useRules=false, the backend creates a rule with empty ruleJson.
These rules have no firstVal, causing TypeError when shouldFilterApp
tries to access rule.firstVal[0].

Refactored to remove all useEffect usage:
- Use useSyncExternalStore for scroll detection (React 19 pattern)
- Move rule filtering to event handlers (updateLibraryId, handleUpdateArrAction)
- Extract helper functions outside component (parseValidRules, shouldFilterApp, filterRulesForArrSettings)

This is cleaner and more aligned with modern React patterns.

* fix: complete merge of main branch rule editor fixes

This commit completes the merge from main that was started in 0923d01.
The original merge included the leftJoinAndSelect changes but missed
removing the empty rule creation code from rules.service.ts.

Backend (rules.service.ts):
- Remove empty rule creation in createRuleGroup and updateRuleGroup
- When useRules=false, the backend was creating rules with empty ruleJson
- This aligns with main's fix from commit 730adb5 (Maintainerr#2270)
- The RemoveEmptyRules migration (already present) cleans up existing data

Frontend (RuleFormPage.tsx):
- Add key={id} prop to AddModal component
- This ensures the form fully remounts when navigating between rule groups
- Replaces the need for useEffect-based form reset logic

Frontend (AddModal/index.tsx):
- Remove parseValidRules helper (no longer needed since empty rules won't exist)
- Remove defensive firstVal check in filterRulesForArrSettings
- Simplify rules state initialization to direct parsing

The root cause was that commit 0923d01 merged the query changes
(leftJoinAndSelect for rules) but not the corresponding code removal
that prevents empty rules from being created in the first place.

* replace more useEffect usage

* prettier

* refactor(ui): replace JS touch detection with CSS media queries

- Remove unused useInteraction, useIsTouch hooks and InteractionContext
- Add CSS @media (hover: hover/none) for touch-friendly MediaCard
- Simplify MediaCard: click always opens modal, CSS handles hover states
- Reduces bundle size by ~150 lines of JS with 15 lines of CSS

* fix click behaviour on mobile

* fix bug on about page showing 0 items in collections

* add support for retrieving seasons and episode view counts

* retrieve admin user ID for UserData fields in getSeasons and getItems

* fix: make watchedAt optional in WatchRecord interface

* add temporary debug for viewdate

* more temp debug

* possible fix for viewdate season/episodes

* prettier

* feat: add getPlaylistItems method to JellyfinAdapterService and update JellyfinGetterService to utilize it

* fix cd/ci tests
Added a warning about the development branch and Jellyfin support.
When upgrading from pre-Jellyfin versions, media_server_type is null.
Instead of defaulting to Plex everywhere (which caused noisy errors if
Plex was down), auto-detect the server type from existing credentials
during settings init and persist it.

- settings.service.ts: auto-detect Jellyfin/Plex from credentials on init
- media-server.factory.ts: throw instead of defaulting to Plex when
  no server type is configured; distinguish "not configured" from
  "unreachable" in startup logging
- collections.service.ts: update return type to match factory change
…ching

Handle partial failures gracefully in Jellyfin adapter. If any single
user query fails when fetching watch history or play counts, the other
successful queries are still processed instead of losing all data.
Query parameters are received as strings. Without ParseIntPipe, numeric
comparisons and math operations work on strings, producing incorrect results.

Added ParseIntPipe with optional: true to:
- media-server.controller.ts: page, limit parameters
- plex-api-legacy.controller.ts: amount parameter
- rules.controller.ts: rulegroupId, typeId parameters

Also refactored rules.controller.ts to use individual @query parameters
instead of inline object types for better type safety.
The Plex adapter's resetMetadataCache() was a no-op despite PlexApiService
having this capability. Now properly delegates to plexApi.resetMetadataCache()
when an itemId is provided.
Instead of hardcoding progress to 100, use the actual PlayedPercentage
from Jellyfin's UserData when available. Falls back to 100 if not set.

Note: Plex API doesn't expose progress percentage in watch history,
so it remains hardcoded to 100 (which is correct for completed watches).
The testResult state was never cleared when URL or API key inputs changed.
Users could modify credentials and save with a stale success indicator.
Now clears test result and testedSettings when credentials are modified.
The factory already handled errors properly, but the warning message
didn't include the actual error details for debugging. Now includes
the error message in the log output.
Remove leftover debug console.log statement from addImportExclusion.
Add comprehensive unit tests for PlexAdapterService covering:
- Lifecycle management (isSetup, initialize, uninitialize)
- Server type identification (returns PLEX)
- Feature detection (LABELS, PLAYLISTS, COLLECTION_VISIBILITY, etc.)
- Cache management with resetMetadataCache delegation
- Server status mapping to MediaServerStatus
- User fetching and mapping to MediaUser
- Library fetching and mapping to MediaLibrary
- Library content queries with Jellyfin ID detection
- Watch history retrieval and mapping to WatchRecord
- Collection operations (create, delete)
- Search content delegation

This brings the test count from 392 to 419 tests.
Jellyfin provides ratings through CommunityRating (typically TMDB,
0-10 scale) and CriticRating (typically Rotten Tomatoes, 0-100 scale
normalized to 0-10).

Map these to the existing rating rule properties:
- rating_imdb/rating_tmdb: Use CommunityRating (TMDB-sourced)
- rating_rottenTomatoesCritic: Use CriticRating
- rating_rottenTomatoesAudience: Use CommunityRating as approximation
- All *Show variants: Traverse to parent/grandparent for show metadata

This enables Jellyfin users to create rules based on ratings, which
previously returned null for all external rating properties.
Jellyfin genres don't have IDs, so we were using array index which was
fragile and could change between requests or items. Now using djb2 hash
algorithm to generate stable IDs from genre names.

Fixes review item #11.
During initial setup, the Plex and Jellyfin tabs were both visible
before the user selected a media server type. Now only the General
tab is shown until a selection is made.

Fixes review item #14.
…switch

Added a switchInProgress flag that:
- Prevents concurrent switch operations from starting
- Blocks factory.getService() calls during the switch
- Is always released in a finally block

This prevents API requests from routing to the wrong adapter during
the window between settings update and server initialization.

Fixes review item #8.
…apters

- Document error handling contract in IMediaServerService interface:
  - Read operations return empty/undefined on failure
  - Write operations throw with descriptive message
- Add try/catch with logging to Plex adapter write operations
- Improve error messages to include context (collection ID, item ID, etc.)

Fixes review item #10.
The lock added to MediaServerFactory caused a circular dependency
between MediaServerModule and SettingsModule. Removed the lock
entirely as it was over-engineering - the switch operation is
rare and short-lived.
- Hide Plex and Tautulli rules when on Jellyfin media server
- Hide Jellyfin rules when on Plex media server
- Use MediaServerType enum from contracts instead of string literals
- Replace string literals 'plex' and 'jellyfin' with MediaServerType enum
- Update Settings/index.tsx to use enum for tab visibility
- Update MediaServerSelector to use enum for all comparisons
… into feat/jellyfin-connection-error-handling
…stream/main)

Resolve conflicts:
- seerr-api.service.ts: use unified logConnectionTestError with 'Seerr' label
- Remove deleted jellyseerr files (unified into seerr)
@enoch85 enoch85 changed the base branch from jellyfin-dev to main February 27, 2026 20:08
Resolve conflict in CollectionItem: keep both resolveImageUrl and formatSize.
Resolve conflicts between the metadata provider abstraction layer
and the Seerr unification + collection size features from main.
enoch85 added a commit that referenced this pull request Mar 9, 2026
…, #2442, #2406, #2386, #2370

PR #2466 - fix: honor Jellyfin played threshold
- Respect configured played percentage threshold for Jellyfin watch status

PR #2461 - feat(rules): add ARR disk target path selection for disk space rules
- Allow selecting specific disk target paths for Radarr/Sonarr disk space rules

PR #2458 - feat: clean up empty ended shows in Sonarr after season actions
- Automatically remove ended shows from Sonarr when all seasons are processed

PR #2453 - fix: improve Plex viewCount reliability and add isWatched boolean
- Use native Plex viewCount field with watch history fallback
- Add new isWatched boolean rule property

PR #2452 - build(deps): bump actions/download-artifact from 7 to 8

PR #2451 - build(deps): bump actions/upload-artifact from 6 to 7

PR #2442 - fix(server): reject null/undefined in numeric rule comparisons
- Add getComparisonResult wrapper that fails closed on null/undefined operands
- Strict type checking for BIGGER/SMALLER comparisons

PR #2406 - Metadata provider abstraction layer with TVDB support
- Add MetadataService as central metadata resolution layer
- TVDB support as alternative metadata provider
- Dynamic provider preference with fallback
- Replace TmdbIdService with unified MetadataService

PR #2386 - feat: missing_episode rules
- Add missing episode count as a rule property for Sonarr

PR #2370 - build(deps-dev): bump the eslint group with 2 updates

enoch85 commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator Author

This is now included in the jellyfin-dev docker container - among some other fixes. To upgrade, change tag in docker from latest (or whatever you have) to jellyfin-dev. Then do:

docker compose pull jellyfin-dev && docker compose up -d

You can check the latest commits here: https://github.com/Maintainerr/Maintainerr/commits/jellyfin-dev

  1. Consider the jellyfin-dev branch to be early development, and make a backup before switching to that branch!
  2. Please test that your issue is fixed
  3. Report back here, and tag me and @ydkmlt84

Thank you very much! 🚀

@enoch85 enoch85 force-pushed the feat/metadata-settings branch from 711d7a0 to 77ac814 Compare March 23, 2026 20:24
enoch85 added a commit that referenced this pull request Mar 24, 2026
PR #2406 - Metadata provider abstraction layer with TVDB support
- Tighten provider settings validation
- Tighten resolver and settings boundaries
- Prefer validated servarr provider lookups
- Fix review findings
- Formatting and lint fixes
enoch85 added a commit that referenced this pull request Mar 24, 2026
PR #2406 - Metadata provider abstraction layer with TVDB support
- Tighten provider settings validation and extract MetadataSettingsService
- Tighten resolver and settings boundaries
- Prefer validated servarr provider lookups via findServarrLookupMatch
- Refactor deleteShowIfEmpty/unmonitorShowIfEmptyAndEnded to use metadata layer
- Resolve TMDB ID through metadata layer in Seerr cleanup
- Fix review findings, formatting and lint fixes
@enoch85

enoch85 commented Mar 24, 2026

Copy link
Copy Markdown
Collaborator Author

@ydkmlt84 Tested and is solid afaics.

@enoch85 enoch85 changed the base branch from main to development March 28, 2026 00:34
@enoch85 enoch85 marked this pull request as draft March 28, 2026 03:41
@enoch85 enoch85 removed the request for review from ydkmlt84 March 28, 2026 03:42
@enoch85 enoch85 force-pushed the development branch 3 times, most recently from 1200852 to ac7e7eb Compare March 30, 2026 21:59
@enoch85

enoch85 commented Mar 31, 2026

Copy link
Copy Markdown
Collaborator Author

This got too diverged. Will close and make a new PR.

@enoch85 enoch85 closed this Mar 31, 2026
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.

Tmdb timeout

2 participants