fix(linking collections): fixes name being propgated across all hubs when linking

adds tooltip to show which hubs will be linked/unlinked
This commit is contained in:
Tom Wheeler
2025-11-27 21:19:03 +13:00
parent de8ed928fe
commit b0320e218e
6 changed files with 388 additions and 20 deletions

View File

@@ -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 {

View File

@@ -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<string>,
existingCollectionIds: Set<string>,
allCollections: PlexCollection[],
enhancedExistingConfigs: PreExistingCollectionConfig[]
enhancedExistingConfigs: PreExistingCollectionConfig[],
existingHubConfigs: PlexHubConfig[],
repairedHubNamesCounter: { count: number }
): Promise<void> {
// 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

View File

@@ -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)

View File

@@ -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<string>('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<PlexHubConfig[]>([]);
// 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 || []}
/>
)}
</>

View File

@@ -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<HTMLDivElement, ModalProps>(
secondaryDisabled = false,
onSecondary,
secondaryText,
secondaryTooltip,
tertiaryButtonType = 'default',
tertiaryDisabled = false,
tertiaryText,
@@ -236,6 +238,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
className="ml-3"
disabled={secondaryDisabled}
data-testid="modal-secondary-button"
title={secondaryTooltip}
>
{secondaryText}
</Button>

View File

@@ -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,