@@ -312,8 +312,13 @@ private async Task<OmbiUser> EnsureOmbiUser(PlexCommunityUser plexUser, UserMana
312312 return null ;
313313 }
314314
315- var existing = await ResolveExistingUser ( plexUser , resolvedNumericId , ct ) ;
315+ var ( existing , usernameCollision ) = await ResolveExistingUser ( plexUser , resolvedNumericId , ct ) ;
316316 if ( existing != null ) return existing ;
317+ // A username collision means another Ombi row already owns this username but we
318+ // refuse to adopt it (e.g. a stale ghost bound to a different numeric id). Creating
319+ // a new row would either trip the unique-username constraint or produce a duplicate
320+ // — skip the target and let the sweep handle the ghost.
321+ if ( usernameCollision ) return null ;
317322
318323 return await CreateOmbiUserIfEligible ( plexUser , resolvedNumericId , userManagement ) ;
319324 }
@@ -345,7 +350,7 @@ private static bool IsBanned(PlexCommunityUser plexUser, string resolvedNumericI
345350 return false ;
346351 }
347352
348- private async Task < OmbiUser > ResolveExistingUser ( PlexCommunityUser plexUser , string resolvedNumericId , CancellationToken ct )
353+ private async Task < ( OmbiUser user , bool usernameCollision ) > ResolveExistingUser ( PlexCommunityUser plexUser , string resolvedNumericId , CancellationToken ct )
349354 {
350355 // Primary lookup: numeric plex.tv id (the canonical identifier). New rows only
351356 // ever get the numeric id, and existing legacy rows have it too.
@@ -357,7 +362,7 @@ private async Task<OmbiUser> ResolveExistingUser(PlexCommunityUser plexUser, str
357362 }
358363 if ( existing != null || string . IsNullOrWhiteSpace ( plexUser . username ) )
359364 {
360- return existing ;
365+ return ( existing , false ) ;
361366 }
362367
363368 // Username fallback: picks up rows whose ProviderUserId is stale / UUID-shaped
@@ -368,16 +373,16 @@ private async Task<OmbiUser> ResolveExistingUser(PlexCommunityUser plexUser, str
368373 // drives the NotAFriend sweep instead.
369374 var usernameMatch = await _ombiUserManager . Users . FirstOrDefaultAsync (
370375 x => x . UserType == UserType . PlexUser && x . UserName == plexUser . username , ct ) ;
371- if ( usernameMatch == null ) return null ;
376+ if ( usernameMatch == null ) return ( null , false ) ;
372377
373378 if ( ! IsUsernameAdoptionSafe ( usernameMatch . ProviderUserId , plexUser . id , resolvedNumericId ) )
374379 {
375380 _logger . LogWarning (
376381 "Plex user '{Username}' ({PlexUserId}) collides with existing Ombi user bound to {ProviderUserId}; skipping" ,
377382 plexUser . username , plexUser . id , usernameMatch . ProviderUserId ) ;
378- return null ;
383+ return ( null , true ) ;
379384 }
380- return usernameMatch ;
385+ return ( usernameMatch , false ) ;
381386 }
382387
383388 // A username match is only safe to adopt when we can convince ourselves it's the same
0 commit comments