Skip to content

fix(plex): reconcile program identity by plex-guid on library scan - #1963

Open
roto31 wants to merge 4 commits into
chrisbenincasa:mainfrom
roto31:fix/plex-identity-reconciliation
Open

fix(plex): reconcile program identity by plex-guid on library scan#1963
roto31 wants to merge 4 commits into
chrisbenincasa:mainfrom
roto31:fix/plex-identity-reconciliation

Conversation

@roto31

@roto31 roto31 commented Jul 22, 2026

Copy link
Copy Markdown

Summary

Fixes playback failures after Plex library reindex / rating key rotation (#1962).

When Plex assigns a new ratingKey to the same logical item, Tunarr previously inserted a new program row (upsert conflict key is externalKey) while channel lineups kept referencing the old program UUID. Streams then called /library/metadata/{staleKey} → 404 → HLS failure.

This PR (movie libraries first):

  • PlexProgramIdentityService — resolve existing programs by plex-guid (skipping synthetic local:// guids) and canonicalId when the current rating key is not found
  • Scan skip guard — only skip when canonicalId matches and the rating key is unchanged
  • ProgramDB.reconcilePlexRatingKeyChange — update program.external_key in place; refresh program_external_id; if a duplicate row already owns the new key, remap channel_programs + channel-lineups/*.json and mark the duplicate missing
  • ProgramExternalIdRepository — include externalKey in onConflictDoUpdate set; add lookupProgramSummaryByPlexGuid
  • PlexIdentityDesyncHealthCheck — warn when active lineups reference programs in missing state

Test plan

  • plexProgramIdentityUtil.test.ts — guid extraction, local:// detection
  • PlexProgramIdentityService.test.ts — rating key rotation, skip logic
  • PlexIdentityDesyncHealthCheck.test.ts — healthy vs warning paths
  • pnpm test in server/ (CI)
  • Manual: Plex reindex → force library scan → channel playback without stale 404s

Follow-up PRs (not in this change)

Item Notes
TV / music / other scanners Same identity path as movies
plexStream.streamPath Wire direct in ProgramStreamDetailsFetcher (setting currently unused)
Stream-time 404 recovery Resolve via plex-guid at playback
Zod soft-fail Quarantine items with missing width/height or invalid duration
SQLite ON CONFLICT migration test Partial unique index alignment for legacy DBs

Closes #1962 for the movie-library identity path; issue can stay open for follow-ups if preferred.

When Plex rating keys rotate after a reindex, scans previously inserted
duplicate program rows while channel lineups kept stale UUIDs, causing
playback 404s on /library/metadata/{ratingKey}.

- Resolve existing movies by plex-guid (and canonicalId) before minting
- Reuse program UUID and update external_key in place via reconcilePlexRatingKeyChange
- Remap channel lineups and channel_programs when a duplicate row exists
- Include externalKey in program_external_id upsert on conflict
- Add PlexIdentityDesyncHealthCheck for missing programs in active lineups

Fixes chrisbenincasa#1962 (movie libraries; TV/music scanners follow in a later PR)
roto31 added 2 commits July 21, 2026 21:33
…ipts.

Honor plexStream.streamPath=direct at playback, reconcile episode rating keys,
defer lastScannedAt on scan failure, soften Plex Zod/duration parsing, add
duplicate-program fixer, and env-based macOS sign/notarize build scripts.
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are all of these for? This shouldn't be necessary for local testing

`Plex episode ID = ${plexEpisode.ratingKey} has invalid duration.`,
),
);
let duration = plexEpisode.duration;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in a separate PR

`Plex movie ID = ${plexMovie.ratingKey} has invalid duration.`,
),
);
let duration = plexMovie.duration;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated to the above - pull it out to a separate method and apply to all media types, not just movies and episodes.

@@ -0,0 +1,94 @@
import { inject, injectable } from 'inversify';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another scope creep - separate PR please.


protected async runInternal(): Promise<void> {
const duplicateGroups = await sql<DuplicateRow>`
SELECT canonical_id AS canonicalId,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not build this with the ORM? would be easier to detect anything that breaks / changes in future updates?


await this.mediaSourceDB.setLibraryLastScannedTime(library.uuid, dayjs());
} catch (e) {
this.logger.error(e, 'Error scanning library %s — lastScannedAt not updated', library.uuid);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another tangential change that is not related to item resolution - should be a separate PR where we can discuss the implications.

program.externalIds.find(
(eid) => eid.sourceType === server.type,
)?.externalFilePath;
const plexExternalId = program.externalIds.find(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another unrelated change; please keep the PR focused.

): Promise<StreamSource> {
if (isNonEmptyString(potentialFilePath)) {
if (await fileExists(potentialFilePath)) {
const diskCandidates = [preferredDirectPath, potentialFilePath].filter(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should change this in this way without much more discussion. This is a critical path and I think there's some misunderstanding of it here.

const directFromDb = plexExternalId?.directFilePath;
const preferDirect =
server.type === 'plex' &&
this.settingsDB.plexSettings().streamPath === 'direct';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned this should all be split out, but this setting is deprecated and cannot be directly set in the UI anymore. Path replacements are the canonical way of getting direct streaming working. Direct is preferred always over network, even if no replacements are configured, if the file is visible and readable by Tunarr.

(eid) => eid.sourceType === server.type,
);
const serverPath = plexExternalId?.externalFilePath;
const directFromDb = plexExternalId?.directFilePath;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is diverging from the path from the program_version in Tunarr then that is an issue.

where: (cp, { eq }) => eq(cp.programUuid, oldProgramUuid),
});

for (const row of channelRows) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should all be in a transaction

);

const reuseProgramUuid =
resolved && (await identityService.reconcileRatingKeyIfChanged(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is much cleaner and less brittle with if/else


describe('PlexIdentityDesyncHealthCheck', () => {
it('returns healthy when no missing programs in lineups', async () => {
const db = {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests here mock the DB, which effectively means we're testing the mock and not the actual health check - refactoring the query at all would mean the tests continue to the pass

private readonly searchRepo: ProgramSearchRepository,
@inject(KEYS.ProgramStateRepository)
private readonly stateRepo: ProgramStateRepository,
@inject(KEYS.ChannelDB) private readonly channelDB: IChannelDB,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a smell and indicates that the reconciliation logic should live in its own service or command

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.

Plex ratingKey identity desync: channels fail to play after reindex / library changes (architectural review)

2 participants