From b0320e218e8ae6f178ea5a09bfbbf4c174c03436 Mon Sep 17 00:00:00 2001 From: Tom Wheeler Date: Thu, 27 Nov 2025 21:19:03 +1300 Subject: [PATCH] fix(linking collections): fixes name being propgated across all hubs when linking adds tooltip to show which hubs will be linked/unlinked --- .../services/DefaultHubConfigService.ts | 77 ++++++++++- .../collections/services/DiscoveryService.ts | 62 +++++++-- .../Forms/CollectionConfigForm.tsx | 127 +++++++++++++++++- .../Views/All/AllCollectionsView.tsx | 125 ++++++++++++++++- src/components/Common/Modal/index.tsx | 3 + src/utils/collections/linkingHandlers.ts | 14 ++ 6 files changed, 388 insertions(+), 20 deletions(-) diff --git a/server/lib/collections/services/DefaultHubConfigService.ts b/server/lib/collections/services/DefaultHubConfigService.ts index 1c1cfb3..3b1d04f 100644 --- a/server/lib/collections/services/DefaultHubConfigService.ts +++ b/server/lib/collections/services/DefaultHubConfigService.ts @@ -18,6 +18,33 @@ export class DefaultHubConfigService { const settings = getSettings(); const hubConfigs = settings.plex.hubConfigs || []; + // Check if any hubs are missing linkIds (legacy hubs from before automatic linking was implemented) + const hasHubsWithoutLinkIds = hubConfigs.some( + (hub) => hub.linkId === undefined && hubConfigs.length > 1 + ); + + // If we have multiple hubs and some are missing linkIds, apply automatic linking and save + if (hasHubsWithoutLinkIds) { + logger.info( + 'Detected hubs without linkIds - applying automatic linking to repair legacy hubs', + { + label: 'Default Hub Config Service', + totalHubs: hubConfigs.length, + } + ); + + const linkedConfigs = this.applyAutomaticLinking(hubConfigs); + settings.plex.hubConfigs = linkedConfigs; + settings.save(); + + logger.info('Automatic linking applied to legacy hubs', { + label: 'Default Hub Config Service', + linkedGroups: this.countLinkedGroups(linkedConfigs), + }); + + return linkedConfigs; + } + return hubConfigs; } @@ -28,6 +55,7 @@ export class DefaultHubConfigService { const settings = getSettings(); // Preserve existing isActive status when updating hub configs + // Also repairs any broken names from the linking bug (names are refreshed from Plex discovery data) const existingHubConfigs = settings.plex.hubConfigs || []; const mergedHubConfigs = newConfigs.map( (newConfig: DiscoveredHubConfig) => { @@ -36,12 +64,30 @@ export class DefaultHubConfigService { existing.hubIdentifier === newConfig.hubIdentifier && existing.libraryId === newConfig.libraryId ); + + // Check if name is being corrected (for logging) + const nameChanged = + existingConfig && existingConfig.name !== newConfig.name; + if (nameChanged) { + logger.info( + `Correcting hub name from "${existingConfig.name}" to "${newConfig.name}"`, + { + label: 'Default Hub Config Service', + hubIdentifier: newConfig.hubIdentifier, + libraryId: newConfig.libraryId, + oldName: existingConfig.name, + newName: newConfig.name, + } + ); + } + return { ...newConfig, // Preserve existing ID or generate new one id: existingConfig?.id || IdGenerator.generateId(), // Preserve existing isActive status, or default to true for new configs isActive: existingConfig?.isActive ?? true, + // Note: name comes from newConfig (discovery data), which fixes any broken names from the linking bug }; } ); @@ -144,8 +190,17 @@ export class DefaultHubConfigService { // For linked collections, preserve library-specific fields libraryId: configToUpdate.libraryId, // Don't change the library assignment libraryName: configToUpdate.libraryName, // Don't change the library name + name: configToUpdate.name, // Don't change the hub display name (library-specific) hubIdentifier: configToUpdate.hubIdentifier, // Don't change the hub identifier mediaType: configToUpdate.mediaType, // Don't change the media type + sortOrderHome: configToUpdate.sortOrderHome, // Library-specific ordering + sortOrderLibrary: configToUpdate.sortOrderLibrary, // Library-specific ordering + isLibraryPromoted: configToUpdate.isLibraryPromoted, // Library-specific promotion status + everLibraryPromoted: configToUpdate.everLibraryPromoted, // Library-specific promotion history + isPromotedToHub: configToUpdate.isPromotedToHub, // Library-specific promotability + missing: configToUpdate.missing, // Library-specific existence status + lastSyncedAt: configToUpdate.lastSyncedAt, // Library-specific sync timestamp + needsSync: configToUpdate.needsSync, // Library-specific sync status // Note: isLinked, linkId, isUnlinked come from settings spread above }; @@ -177,6 +232,7 @@ export class DefaultHubConfigService { const existingHubConfigs = settings.plex.hubConfigs || []; // Add isActive field and default time restrictions server-side + // Also repairs any broken names from the linking bug (names are refreshed from Plex discovery data) const hubConfigsWithActiveStatus = newConfigs.map( (config: DiscoveredHubConfig) => { // Try to find existing hub by natural key to preserve ID @@ -186,6 +242,22 @@ export class DefaultHubConfigService { existing.libraryId === config.libraryId ); + // Check if name is being corrected (for logging) + const nameChanged = + existingConfig && existingConfig.name !== config.name; + if (nameChanged) { + logger.info( + `Correcting hub name from "${existingConfig.name}" to "${config.name}"`, + { + label: 'Default Hub Config Service', + hubIdentifier: config.hubIdentifier, + libraryId: config.libraryId, + oldName: existingConfig.name, + newName: config.name, + } + ); + } + return { ...config, // Preserve existing ID or generate new one @@ -194,6 +266,7 @@ export class DefaultHubConfigService { timeRestriction: config.timeRestriction || { alwaysActive: true, // Default to always active }, + // Note: name comes from config (discovery data), which fixes any broken names from the linking bug }; } ); @@ -264,11 +337,13 @@ export class DefaultHubConfigService { ); // Link all hubs in this group + // BUT: respect isUnlinked flag - don't re-link deliberately unlinked hubs hubs.forEach((hub: PlexHubConfig) => { resultConfigs.push({ ...hub, - isLinked: true, + isLinked: hub.isUnlinked ? false : true, // Don't re-link if deliberately unlinked linkId, + // Keep isUnlinked as-is - it remains true if user deliberately unlinked }); }); } else { diff --git a/server/lib/collections/services/DiscoveryService.ts b/server/lib/collections/services/DiscoveryService.ts index 071284b..ea64e40 100644 --- a/server/lib/collections/services/DiscoveryService.ts +++ b/server/lib/collections/services/DiscoveryService.ts @@ -186,6 +186,8 @@ export class DiscoveryService { await this.resetPreExistingPromotionStatus(); // STEP 3: Discover hubs and enhance pre-existing collections with hub data + // Use an object to pass repairedHubNamesCount by reference so it can be modified + const repairedHubNamesCounter = { count: 0 }; await this.discoverHubsAndEnhance( plexClient, libraries, @@ -197,7 +199,9 @@ export class DiscoveryService { existingCollectionKeys, existingCollectionIds, allCollections, - enhancedExistingConfigs + enhancedExistingConfigs, + existingHubConfigs, + repairedHubNamesCounter ); // STEP 4: Promote collections that should be visible but aren't in hub management @@ -286,21 +290,31 @@ export class DiscoveryService { // STEP 4: Update settings with discovered configs if requested if (updateSettings) { - // Add discovered hub configs to settings + // Add discovered hub configs to settings using DefaultHubConfigService + // This ensures automatic linking is applied properly if (discoveredHubConfigs.length > 0) { - const existingHubConfigs = settings.plex.hubConfigs || []; - const newHubConfigs = [...existingHubConfigs]; + const { defaultHubConfigService } = await import( + '@server/lib/collections/services/DefaultHubConfigService' + ); - for (const discoveredHub of discoveredHubConfigs) { - // Add isActive: true to make it a complete PlexHubConfig - newHubConfigs.push({ ...discoveredHub, isActive: true }); - } + // Use appendConfigs which applies automatic linking logic + // This also saves the repaired names from existing hubs + defaultHubConfigService.appendConfigs(discoveredHubConfigs); - settings.plex.hubConfigs = newHubConfigs; - logger.debug( - `Added ${discoveredHubConfigs.length} new hub configs to settings`, + logger.info( + `Added ${discoveredHubConfigs.length} new hub configs to settings with automatic linking`, { label: 'Discovery Service', + repairedNamesCount: repairedHubNamesCounter.count, + } + ); + } else if (repairedHubNamesCounter.count > 0) { + // No new hubs, but we repaired existing hub names - save settings + settings.save(); + logger.info( + `Repaired ${repairedHubNamesCounter.count} existing hub names (no new hubs discovered)`, + { + label: 'Discovery Service - Name Repair', } ); } @@ -715,7 +729,9 @@ export class DiscoveryService { existingCollectionKeys: Set, existingCollectionIds: Set, allCollections: PlexCollection[], - enhancedExistingConfigs: PreExistingCollectionConfig[] + enhancedExistingConfigs: PreExistingCollectionConfig[], + existingHubConfigs: PlexHubConfig[], + repairedHubNamesCounter: { count: number } ): Promise { // Counters for summary logging let skippedAgregarrCollections = 0; @@ -898,6 +914,28 @@ export class DiscoveryService { if (!existingHubKeys.has(hubKey)) { discoveredHubConfigs.push(hubConfig); processedHubs++; + } else { + // Hub already exists - check if name needs repair (from linking bug) + const existingHub = existingHubConfigs.find( + (h: PlexHubConfig) => + h.hubIdentifier === hubConfig.hubIdentifier && + h.libraryId === hubConfig.libraryId + ); + if (existingHub && existingHub.name !== hubConfig.name) { + logger.info( + `Repairing hub name from "${existingHub.name}" to "${hubConfig.name}"`, + { + label: 'Discovery Service - Name Repair', + hubIdentifier: hubConfig.hubIdentifier, + libraryId: hubConfig.libraryId, + oldName: existingHub.name, + newName: hubConfig.name, + } + ); + // Update the existing hub's name directly + existingHub.name = hubConfig.name; + repairedHubNamesCounter.count++; + } } } else if (parsedHub.ratingKey) { // This has a rating key - check if it's an Agregarr collection or pre-existing diff --git a/src/components/Collections/Forms/CollectionConfigForm.tsx b/src/components/Collections/Forms/CollectionConfigForm.tsx index 9c1af2a..c2e3a51 100644 --- a/src/components/Collections/Forms/CollectionConfigForm.tsx +++ b/src/components/Collections/Forms/CollectionConfigForm.tsx @@ -14,7 +14,7 @@ import { SMART_COLLECTION_SORT_OPTIONS } from '@app/types/collections'; import { Transition } from '@headlessui/react'; import { Field, Formik, type FormikErrors, type FormikTouched } from 'formik'; import type React from 'react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -120,6 +120,117 @@ const CollectionFormConfigForm = ({ // State for preview modal const [showPreview, setShowPreview] = useState(false); + // Generate tooltip showing which other items will be affected - MUST be before early returns + const linkingTooltip = useMemo(() => { + if (!config) return undefined; + + const isHub = + config.collectionType === 'default_plex_hub' || + (config as CollectionFormConfig).configType === 'hub'; + const isPreExisting = + config.collectionType === 'pre_existing' || + (config as CollectionFormConfig).configType === 'preExisting'; + const isCollection = !isHub && !isPreExisting; + const isLinked = Boolean(config.isLinked && !config.isUnlinked); + + if (isLinked) { + // Unlink button - show what will be unlinked + if (isHub && allHubConfigs) { + const hubConfig = config as PlexHubConfig; + const linkedHubs = allHubConfigs.filter( + (h: PlexHubConfig) => + h.linkId === config.linkId && h.isLinked && h.id !== config.id + ); + if (linkedHubs.length > 0) { + const currentHubText = `${hubConfig.name} (${hubConfig.libraryName})`; + const otherHubsText = linkedHubs + .map((h) => `${h.name} (${h.libraryName})`) + .join('\n'); + return `Will unlink ${ + linkedHubs.length + 1 + } hubs:\n${currentHubText}\n${otherHubsText}`; + } + } else if (isCollection && allCollectionConfigs) { + const collectionConfig = config as CollectionFormConfig; + const linkedCollections = allCollectionConfigs.filter( + (c: CollectionFormConfig) => + c.type === collectionConfig.type && + c.subtype === collectionConfig.subtype && + c.linkId === collectionConfig.linkId && + c.isLinked && + c.id !== collectionConfig.id + ); + if (linkedCollections.length > 0) { + const currentLibName = + libraries?.find((lib) => lib.key === collectionConfig.libraryId) + ?.name || 'Unknown'; + const currentText = `${config.name} (${currentLibName})`; + const otherTexts = linkedCollections + .map((c) => { + const libName = + libraries?.find((lib) => lib.key === c.libraryId)?.name || + 'Unknown'; + return `${c.name} (${libName})`; + }) + .join('\n'); + return `Will unlink ${ + linkedCollections.length + 1 + } collections:\n${currentText}\n${otherTexts}`; + } + } + } else if (config.linkId) { + // Check if can link + if (isHub && allHubConfigs) { + const hubConfig = config as PlexHubConfig; + const eligibleHubs = allHubConfigs.filter( + (h: PlexHubConfig) => + h.linkId !== undefined && + h.linkId === config.linkId && + h.id !== config.id && + !h.isLinked + ); + if (eligibleHubs.length > 0) { + const currentHubText = `${hubConfig.name} (${hubConfig.libraryName})`; + const otherHubsText = eligibleHubs + .map((h) => `${h.name} (${h.libraryName})`) + .join('\n'); + return `Will link ${ + eligibleHubs.length + 1 + } hubs:\n${currentHubText}\n${otherHubsText}`; + } + } else if (isCollection && allCollectionConfigs) { + const collectionConfig = config as CollectionFormConfig; + const eligibleCollections = allCollectionConfigs.filter( + (c: CollectionFormConfig) => + c.type === collectionConfig.type && + c.subtype === collectionConfig.subtype && + c.linkId !== undefined && + c.linkId === collectionConfig.linkId && + !c.isLinked && + c.id !== collectionConfig.id + ); + if (eligibleCollections.length > 0) { + const currentLibName = + libraries?.find((lib) => lib.key === collectionConfig.libraryId) + ?.name || 'Unknown'; + const currentText = `${config.name} (${currentLibName})`; + const otherTexts = eligibleCollections + .map((c) => { + const libName = + libraries?.find((lib) => lib.key === c.libraryId)?.name || + 'Unknown'; + return `${c.name} (${libName})`; + }) + .join('\n'); + return `Will link ${ + eligibleCollections.length + 1 + } collections:\n${currentText}\n${otherTexts}`; + } + } + } + return undefined; + }, [config, allHubConfigs, allCollectionConfigs, libraries]); + // Validation schema for collections, hubs, and pre-existing configs const CollectionFormConfigSchema = Yup.object().shape({ // Only validate type/subtype for full collections, not hubs/pre-existing @@ -478,7 +589,8 @@ const CollectionFormConfigForm = ({ const isCollection = !isHub && !isPreExisting; // Regular Agregarr collections // Use unified linking approach - check if actively linked - const isLinked = Boolean(config.isLinked); + // If isUnlinked is true, treat as NOT linked (available for re-linking) + const isLinked = Boolean(config.isLinked && !config.isUnlinked); // Determine if this config can be linked (for showing link button) // Only show link button for existing configs that are unlinked but could be linked @@ -489,9 +601,14 @@ const CollectionFormConfigForm = ({ // For hubs: check if there are other unlinked hubs with same linkId // Include hubs with isUnlinked flag - those can be relinked! if (!allHubConfigs) return false; + // Must have valid linkId to be linkable (prevent undefined === undefined) + if (config.linkId === undefined) return false; const eligibleHubs = allHubConfigs.filter( (h: PlexHubConfig) => - h.linkId === config.linkId && h.id !== config.id && !h.isLinked + h.linkId !== undefined && // Must have valid linkId + h.linkId === config.linkId && + h.id !== config.id && + !h.isLinked // Note: We don't exclude isUnlinked hubs - they can be relinked ); return eligibleHubs.length > 0; @@ -499,10 +616,13 @@ const CollectionFormConfigForm = ({ // For collections: check if there are other unlinked collections with same type/subtype/linkId if (!allCollectionConfigs) return false; const collectionConfig = config as CollectionFormConfig; + // Must have valid linkId to be linkable (prevent undefined === undefined) + if (collectionConfig.linkId === undefined) return false; const eligibleCollections = allCollectionConfigs.filter( (c: CollectionFormConfig) => c.type === collectionConfig.type && c.subtype === collectionConfig.subtype && + c.linkId !== undefined && // Must have valid linkId c.linkId === collectionConfig.linkId && !c.isLinked && c.id !== collectionConfig.id @@ -1244,6 +1364,7 @@ const CollectionFormConfigForm = ({ : 'Link' : undefined } + secondaryTooltip={linkingTooltip} secondaryButtonType={isLinked ? 'warning' : 'primary'} // Add preview button for collections (not hubs or pre-existing) // Disable for overseerr individual user requests (type=overseerr, subtype=users) diff --git a/src/components/Collections/Views/All/AllCollectionsView.tsx b/src/components/Collections/Views/All/AllCollectionsView.tsx index dd2329c..ddb54aa 100644 --- a/src/components/Collections/Views/All/AllCollectionsView.tsx +++ b/src/components/Collections/Views/All/AllCollectionsView.tsx @@ -10,6 +10,10 @@ import PageTitle from '@app/components/Common/PageTitle'; import { useCollectionEdit } from '@app/hooks/collections/useCollectionEdit'; import type { CollectionFormConfig, Library } from '@app/types/collections'; import { formatSyncScheduleBadge } from '@app/utils/collections/collectionUtils'; +import { + linkCollectionConfig, + unlinkCollectionConfig, +} from '@app/utils/collections/linkingHandlers'; import { ArrowPathIcon, CheckIcon, @@ -27,8 +31,9 @@ import type { } from '@server/lib/settings'; import axios from 'axios'; import type React from 'react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages({ @@ -65,6 +70,7 @@ interface DisplayCollection { const AllCollectionsView: React.FC = () => { const intl = useIntl(); + const { addToast } = useToasts(); // Use the shared collection edit hook for collections only const { @@ -90,9 +96,11 @@ const AllCollectionsView: React.FC = () => { const [filterLibrary, setFilterLibrary] = useState('all'); // Fetch data from separate APIs for consistency with CollectionSettings - const { data: collectionData, error: collectionError } = useSWR( - '/api/v1/collections' - ); + const { + data: collectionData, + error: collectionError, + mutate: revalidateCollections, + } = useSWR('/api/v1/collections'); const { data: libraries = [], error: librariesError } = useSWR( '/api/v1/settings/plex/libraries' ); @@ -107,6 +115,37 @@ const AllCollectionsView: React.FC = () => { mutate: revalidatePreExisting, } = useSWR('/api/v1/preexisting'); + // Local state for linking operations + const [localCollectionConfigs, setLocalCollectionConfigs] = useState< + CollectionFormConfig[] + >([]); + const [localHubConfigs, setLocalHubConfigs] = useState([]); + + // Update local state when data changes + useEffect(() => { + if (collectionData?.collectionConfigs) { + setLocalCollectionConfigs(collectionData.collectionConfigs); + } + if (hubConfigs) { + setLocalHubConfigs(hubConfigs); + } + }, [collectionData, hubConfigs]); + + // Revalidate all data sources + const revalidateAll = () => { + revalidateCollections(); + revalidateDefaultHubs(); + revalidatePreExisting(); + }; + + // Wrapper for saveCollectionConfig to match linking handler signature + const saveCollectionConfigs = async (configs: CollectionFormConfig[]) => { + // Save each config individually + for (const config of configs) { + await saveCollectionConfig(config); + } + }; + const isLoading = !collectionData || !libraries || !hubConfigs || !preExistingConfigs; const hasError = @@ -1218,6 +1257,32 @@ const AllCollectionsView: React.FC = () => { onSave={saveCollectionConfig} onCancel={closeCollectionModal} libraries={libraries} + onUnlink={(config) => + unlinkCollectionConfig(config, { + localCollectionConfigs: + collectionData?.collectionConfigs || localCollectionConfigs, + localHubConfigs: hubConfigs || localHubConfigs, + setLocalCollectionConfigs, + setLocalHubConfigs, + revalidateAll, + addToast, + saveCollectionConfigs, + }) + } + onLink={(config) => + linkCollectionConfig(config, { + localCollectionConfigs: + collectionData?.collectionConfigs || localCollectionConfigs, + localHubConfigs: hubConfigs || localHubConfigs, + setLocalCollectionConfigs, + setLocalHubConfigs, + revalidateAll, + addToast, + saveCollectionConfigs, + }) + } + allCollectionConfigs={collectionData?.collectionConfigs || []} + allHubConfigs={hubConfigs || []} /> )} @@ -1228,6 +1293,32 @@ const AllCollectionsView: React.FC = () => { onSave={saveHubConfig} onCancel={closeHubModal} libraries={libraries} + onUnlink={(config) => + unlinkCollectionConfig(config, { + localCollectionConfigs: + collectionData?.collectionConfigs || localCollectionConfigs, + localHubConfigs: hubConfigs || localHubConfigs, + setLocalCollectionConfigs, + setLocalHubConfigs, + revalidateAll, + addToast, + saveCollectionConfigs, + }) + } + onLink={(config) => + linkCollectionConfig(config, { + localCollectionConfigs: + collectionData?.collectionConfigs || localCollectionConfigs, + localHubConfigs: hubConfigs || localHubConfigs, + setLocalCollectionConfigs, + setLocalHubConfigs, + revalidateAll, + addToast, + saveCollectionConfigs, + }) + } + allCollectionConfigs={collectionData?.collectionConfigs || []} + allHubConfigs={hubConfigs || []} /> )} @@ -1238,6 +1329,32 @@ const AllCollectionsView: React.FC = () => { onSave={savePreExistingConfig} onCancel={closePreExistingModal} libraries={libraries} + onUnlink={(config) => + unlinkCollectionConfig(config, { + localCollectionConfigs: + collectionData?.collectionConfigs || localCollectionConfigs, + localHubConfigs: hubConfigs || localHubConfigs, + setLocalCollectionConfigs, + setLocalHubConfigs, + revalidateAll, + addToast, + saveCollectionConfigs, + }) + } + onLink={(config) => + linkCollectionConfig(config, { + localCollectionConfigs: + collectionData?.collectionConfigs || localCollectionConfigs, + localHubConfigs: hubConfigs || localHubConfigs, + setLocalCollectionConfigs, + setLocalHubConfigs, + revalidateAll, + addToast, + saveCollectionConfigs, + }) + } + allCollectionConfigs={collectionData?.collectionConfigs || []} + allHubConfigs={hubConfigs || []} /> )} diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 3296812..ebf542b 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -20,6 +20,7 @@ interface ModalProps { cancelText?: string; okText?: string; secondaryText?: string; + secondaryTooltip?: string; tertiaryText?: string; okDisabled?: boolean; cancelButtonType?: ButtonType; @@ -56,6 +57,7 @@ const Modal = React.forwardRef( secondaryDisabled = false, onSecondary, secondaryText, + secondaryTooltip, tertiaryButtonType = 'default', tertiaryDisabled = false, tertiaryText, @@ -236,6 +238,7 @@ const Modal = React.forwardRef( className="ml-3" disabled={secondaryDisabled} data-testid="modal-secondary-button" + title={secondaryTooltip} > {secondaryText} diff --git a/src/utils/collections/linkingHandlers.ts b/src/utils/collections/linkingHandlers.ts index 5c669ea..d227990 100644 --- a/src/utils/collections/linkingHandlers.ts +++ b/src/utils/collections/linkingHandlers.ts @@ -41,8 +41,21 @@ export const linkCollectionConfig = async ( ); if (!currentHub) return; + // Hubs must have a valid linkId to be linkable (prevent undefined === undefined matching ALL hubs) + if (currentHub.linkId === undefined) { + addToast( + 'This hub does not have a link group ID. Please run hub discovery to enable linking.', + { + autoDismiss: true, + appearance: 'warning', + } + ); + return; + } + const eligibleHubs = localHubConfigs.filter( (h: PlexHubConfig) => + h.linkId !== undefined && // Must have a valid linkId (prevent undefined === undefined) h.linkId === currentHub.linkId && // Same linkId group (established during discovery) h.id !== config.id && !h.isLinked // Only link hubs that aren't already linked @@ -250,6 +263,7 @@ export const unlinkCollectionConfig = async ( const currentHub = localHubConfigs.find( (h: PlexHubConfig) => h.id === config.id ); + if (!currentHub || !currentHub.isLinked || !currentHub.linkId) { addToast('This hub is not linked to any other hubs.', { autoDismiss: true,