fix(plex): reconcile program identity by plex-guid on library scan - #1963
fix(plex): reconcile program identity by plex-guid on library scan#1963roto31 wants to merge 4 commits into
Conversation
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)
…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 | |||
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
This should be in a separate PR
| `Plex movie ID = ${plexMovie.ratingKey} has invalid duration.`, | ||
| ), | ||
| ); | ||
| let duration = plexMovie.duration; |
There was a problem hiding this comment.
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'; | |||
There was a problem hiding this comment.
Another scope creep - separate PR please.
|
|
||
| protected async runInternal(): Promise<void> { | ||
| const duplicateGroups = await sql<DuplicateRow>` | ||
| SELECT canonical_id AS canonicalId, |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
Another unrelated change; please keep the PR focused.
| ): Promise<StreamSource> { | ||
| if (isNonEmptyString(potentialFilePath)) { | ||
| if (await fileExists(potentialFilePath)) { | ||
| const diskCandidates = [preferredDirectPath, potentialFilePath].filter( |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
This should all be in a transaction
| ); | ||
|
|
||
| const reuseProgramUuid = | ||
| resolved && (await identityService.reconcileRatingKeyIfChanged( |
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
I think this is a smell and indicates that the reconciliation logic should live in its own service or command
Summary
Fixes playback failures after Plex library reindex / rating key rotation (#1962).
When Plex assigns a new
ratingKeyto the same logical item, Tunarr previously inserted a newprogramrow (upsert conflict key isexternalKey) 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 byplex-guid(skipping syntheticlocal://guids) andcanonicalIdwhen the current rating key is not foundcanonicalIdmatches and the rating key is unchangedProgramDB.reconcilePlexRatingKeyChange— updateprogram.external_keyin place; refreshprogram_external_id; if a duplicate row already owns the new key, remapchannel_programs+channel-lineups/*.jsonand mark the duplicatemissingProgramExternalIdRepository— includeexternalKeyinonConflictDoUpdateset; addlookupProgramSummaryByPlexGuidPlexIdentityDesyncHealthCheck— warn when active lineups reference programs inmissingstateTest plan
plexProgramIdentityUtil.test.ts— guid extraction,local://detectionPlexProgramIdentityService.test.ts— rating key rotation, skip logicPlexIdentityDesyncHealthCheck.test.ts— healthy vs warning pathspnpm testinserver/(CI)Follow-up PRs (not in this change)
plexStream.streamPathdirectinProgramStreamDetailsFetcher(setting currently unused)plex-guidat playbackON CONFLICTmigration testCloses #1962 for the movie-library identity path; issue can stay open for follow-ups if preferred.