fix: existing sort title not being preserved for pre-existing collections

This commit is contained in:
Tom Wheeler
2025-08-26 23:23:18 +12:00
parent ae3e1c3719
commit 9a9b032b4f
14 changed files with 245 additions and 51 deletions

View File

@@ -3221,7 +3221,7 @@ components:
securitySchemes:
cookieAuth:
type: apiKey
name: connect.sid
name: agregarr.sid
in: cookie
apiKey:
type: apiKey

View File

@@ -938,14 +938,30 @@ export abstract class BaseCollectionSync implements CollectionSyncInterface {
);
}
// Update sort title if needed
if (sortOrderLibrary !== undefined) {
// Update sort title if needed - for Agregarr-created collections
// Find the config to check everLibraryPromoted status
const settings = getSettings();
const allConfigs = settings.plex.collectionConfigs || [];
const matchingConfig = allConfigs.find((config) => {
const configLibraryId = Array.isArray(config.libraryId)
? config.libraryId[0]
: config.libraryId;
return (
configLibraryId === options.libraryKey &&
config.collectionRatingKey === collectionRatingKey
);
});
// Only update sortTitle if everLibraryPromoted is not explicitly false
if (
sortOrderLibrary !== undefined &&
matchingConfig?.everLibraryPromoted !== false
) {
let sortTitle: string;
const updateConfig: Partial<CollectionConfig> = {};
if (isLibraryPromoted && sortOrderLibrary > 0) {
// Promoted collections get exclamation marks for manual positioning
const settings = getSettings();
const allConfigs = settings.plex.collectionConfigs || [];
// Promoted: Set exclamation marks
const sameLibraryConfigs = allConfigs.filter((config) => {
const configLibraryId = Array.isArray(config.libraryId)
? config.libraryId[0]
@@ -969,14 +985,21 @@ export abstract class BaseCollectionSync implements CollectionSyncInterface {
sortTitle = `!!${collectionName}`;
}
} else {
// A-Z collections use natural title for alphabetical sorting
// Demoted: Reset to natural title and mark as cleaned
sortTitle = collectionName;
// After reset, set everLibraryPromoted back to false
updateConfig.everLibraryPromoted = false;
}
await plexClient.updateCollectionSortTitle(
collectionRatingKey,
sortTitle
);
// Update config if everLibraryPromoted needs to be reset
if (updateConfig.everLibraryPromoted !== undefined && matchingConfig) {
this.updateCollectionConfigField(matchingConfig.id, updateConfig);
}
}
// Update visibility settings
@@ -1045,6 +1068,44 @@ export abstract class BaseCollectionSync implements CollectionSyncInterface {
}
}
/**
* Update specific fields of a collection config
*/
private updateCollectionConfigField(
configId: string,
updateConfig: Partial<CollectionConfig>
): void {
try {
const settings = getSettings();
const collectionConfigs = settings.plex.collectionConfigs || [];
const configIndex = collectionConfigs.findIndex((c) => c.id === configId);
if (configIndex !== -1) {
collectionConfigs[configIndex] = {
...collectionConfigs[configIndex],
...updateConfig,
};
settings.plex.collectionConfigs = collectionConfigs;
settings.save();
logger.debug(`Updated collection config fields: ${configId}`, {
label: `${this.source} Collections`,
configId,
updatedFields: Object.keys(updateConfig),
});
}
} catch (error) {
logger.error(
`Failed to update collection config fields for ${configId}`,
{
label: `${this.source} Collections`,
configId,
error: error instanceof Error ? error.message : String(error),
}
);
}
}
// Abstract methods that must be implemented by subclasses
/**

View File

@@ -473,7 +473,7 @@ export class HubSyncService {
type: 'collection',
libraryId,
collectionRatingKey: ratingKeyForLibrary,
sortOrder: config.sortOrderHome || 0,
sortOrder: config.sortOrderHome || 1,
});
}
}
@@ -494,7 +494,7 @@ export class HubSyncService {
for (const [libraryId, libraryHubConfigs] of hubConfigsByLibrary) {
// Sort hub configs by their sortOrderHome (this is our UI order for home/recommended)
const sortedHubConfigs = [...libraryHubConfigs].sort(
(a, b) => (a.sortOrderHome || 0) - (b.sortOrderHome || 0)
(a, b) => (a.sortOrderHome || 1) - (b.sortOrderHome || 1)
);
// Add hubs to ordering in UI order
@@ -522,7 +522,7 @@ export class HubSyncService {
type: 'hub',
libraryId: hubConfig.libraryId,
hubIdentifier: hubConfig.hubIdentifier,
sortOrder: hubConfig.sortOrderHome || 0,
sortOrder: hubConfig.sortOrderHome || 1,
});
} else {
logger.warn(
@@ -559,7 +559,7 @@ export class HubSyncService {
for (const [libraryId, libraryConfigs] of configsByLibrary) {
// Sort configs by their sortOrderHome (this is our UI order for home/recommended)
const sortedConfigs = [...libraryConfigs].sort(
(a, b) => (a.sortOrderHome || 0) - (b.sortOrderHome || 0)
(a, b) => (a.sortOrderHome || 1) - (b.sortOrderHome || 1)
);
// Add pre-existing collections to ordering in UI order
@@ -586,7 +586,7 @@ export class HubSyncService {
type: 'collection',
libraryId: config.libraryId,
collectionRatingKey: config.collectionRatingKey,
sortOrder: config.sortOrderHome || 0,
sortOrder: config.sortOrderHome || 1,
});
});
}
@@ -710,15 +710,17 @@ export class HubSyncService {
continue;
}
// Only update sortTitle if sortOrderLibrary is defined
if (config.sortOrderLibrary === undefined) {
// Only update sortTitle if everLibraryPromoted is not explicitly false
if (config.everLibraryPromoted === false) {
// If everLibraryPromoted is explicitly false: DO NOT touch sortTitle at all
continue;
}
let sortTitle: string;
const updateConfig: Partial<PreExistingCollectionConfig> = {};
if (config.isLibraryPromoted && config.sortOrderLibrary > 0) {
// Promoted pre-existing collections get exclamation marks
// Promoted: Set exclamation marks
const sameLibraryConfigs = preExistingConfigs.filter(
(c) =>
c.libraryId === config.libraryId &&
@@ -738,8 +740,10 @@ export class HubSyncService {
sortTitle = `!!${config.name}`;
}
} else {
// A-Z pre-existing collections use natural title for alphabetical sorting
// Demoted: Reset to natural title and mark as cleaned
sortTitle = config.name;
// After reset, set everLibraryPromoted back to false
updateConfig.everLibraryPromoted = false;
}
try {
@@ -748,6 +752,11 @@ export class HubSyncService {
sortTitle
);
// Update config if everLibraryPromoted needs to be reset
if (updateConfig.everLibraryPromoted !== undefined) {
this.updatePreExistingConfigField(config.id, updateConfig);
}
logger.debug(
`Updated sortTitle for pre-existing collection ${config.name}: ${sortTitle}`,
{
@@ -893,6 +902,50 @@ export class HubSyncService {
);
}
}
/**
* Update specific fields of a pre-existing collection config
*/
private updatePreExistingConfigField(
configId: string,
updateConfig: Partial<PreExistingCollectionConfig>
): void {
try {
const settings = getSettings();
const preExistingConfigs =
settings.plex.preExistingCollectionConfigs || [];
const configIndex = preExistingConfigs.findIndex(
(c) => c.id === configId
);
if (configIndex !== -1) {
preExistingConfigs[configIndex] = {
...preExistingConfigs[configIndex],
...updateConfig,
};
settings.plex.preExistingCollectionConfigs = preExistingConfigs;
settings.save();
logger.debug(
`Updated pre-existing collection config fields: ${configId}`,
{
label: 'Hub Sync Service',
configId,
updatedFields: Object.keys(updateConfig),
}
);
}
} catch (error) {
logger.error(
`Failed to update pre-existing collection config fields for ${configId}`,
{
label: 'Hub Sync Service',
configId,
error: extractErrorMessage(error),
}
);
}
}
}
export default HubSyncService;

View File

@@ -67,7 +67,7 @@ export class PreExistingCollectionConfigService {
libraryId: newConfig.libraryId,
libraryName: newConfig.libraryName,
mediaType: newConfig.mediaType,
sortOrderHome: newConfig.sortOrderHome || 0,
sortOrderHome: newConfig.sortOrderHome || 1,
sortOrderLibrary: newConfig.sortOrderLibrary || 0,
isLibraryPromoted:
newConfig.isLibraryPromoted ??
@@ -210,7 +210,7 @@ export class PreExistingCollectionConfigService {
libraryId: config.libraryId,
libraryName: config.libraryName,
mediaType: config.mediaType,
sortOrderHome: config.sortOrderHome || 0,
sortOrderHome: config.sortOrderHome || 1,
sortOrderLibrary: config.sortOrderLibrary || 0,
isLibraryPromoted:
config.isLibraryPromoted ??

View File

@@ -441,8 +441,14 @@ export function createHubConfigFromDiscovery(
libraryName: library.title,
mediaType,
sortOrderLibrary: sortOrder.library,
sortOrderHome: sortOrder.home,
sortOrderHome:
hubData.promotedToSharedHome ||
hubData.promotedToOwnHome ||
hubData.promotedToRecommended
? sortOrder.home
: 0, // 0 for void if not visible on any home/recommended screen
isLibraryPromoted: false, // Hubs start in A-Z section (though they use different ordering logic)
everLibraryPromoted: false, // Default: false for all discovered hubs
collectionType: categorization.collectionType,
visibilityConfig: {
usersHome: hubData.promotedToSharedHome || false,
@@ -490,8 +496,14 @@ export function createPreExistingConfigFromDiscovery(
mediaType: detectedMediaType,
titleSort: collectionData.titleSort, // Preserve titleSort for alphabetical sorting
sortOrderLibrary: 0, // All discovered collections start in A-Z section with sortOrderLibrary: 0
sortOrderHome: sortOrder.home,
sortOrderHome:
collectionData.promotedToSharedHome ||
collectionData.promotedToOwnHome ||
collectionData.promotedToRecommended
? sortOrder.home
: 0, // 0 for void if not visible on any home/recommended screen
isLibraryPromoted: false, // All discovered collections start in A-Z section
everLibraryPromoted: false, // Default: false for all discovered collections
collectionType: CollectionType.PRE_EXISTING,
visibilityConfig: {
usersHome: collectionData.promotedToSharedHome || false,

View File

@@ -45,12 +45,13 @@ export interface CollectionConfig {
readonly customDays?: number; // Number of days for Tautulli collections (required for Tautulli type)
readonly libraryId: string; // Library ID this collection belongs to
readonly libraryName: string; // Library name for display
readonly sortOrderHome?: number; // Order for Plex home screen (creation time based)
readonly sortOrderHome?: number; // Order for Plex home screen (1+ for positioned items, 0 for void/unpositioned)
readonly sortOrderLibrary?: number; // Order for Plex library tab (0 for A-Z section, 1+ for promoted section)
readonly isLibraryPromoted?: boolean; // true = promoted section (uses exclamation marks), false = A-Z section (defaults to true for Agregarr collections)
readonly isLinked?: boolean; // True if collection is actively linked to other collections
readonly linkId?: number; // Group ID for linked collections (preserved even when isLinked=false)
readonly isUnlinked?: boolean; // True if this collection was deliberately unlinked and should not be grouped with siblings
everLibraryPromoted?: boolean; // True if this collection has ever been promoted to the promoted section (once true, stays true until sortTitle reset)
readonly collectionRatingKey?: string; // Plex collection rating key (when created)
// Custom URL fields for external collections
readonly tmdbCustomCollectionUrl?: string;
@@ -116,7 +117,7 @@ export interface PlexHubConfig {
libraryId: string; // Library ID this hub belongs to
libraryName: string; // Library display name
mediaType: 'movie' | 'tv'; // Media type (hubs are always single type)
sortOrderHome: number; // Position on Plex home screen
sortOrderHome: number; // Position on Plex home screen (1+ for positioned items, 0 for void)
sortOrderLibrary: number; // Position in library (0 for A-Z section, 1+ for promoted section)
isLibraryPromoted: boolean; // true = promoted section (uses exclamation marks), false = A-Z section
visibilityConfig: {
@@ -135,6 +136,7 @@ export interface PlexHubConfig {
isLinked?: boolean; // True if hub is actively linked to other hubs (set by backend linking logic)
linkId?: number; // Group ID for linked hubs (set by backend linking logic)
isUnlinked?: boolean; // True if this hub was deliberately unlinked and should not be grouped with siblings
everLibraryPromoted?: boolean; // True if this hub has ever been promoted to the promoted section (once true, stays true until sortTitle reset)
// Time restriction settings - all hub types can have time restrictions
timeRestriction?: {
readonly alwaysActive: boolean; // If true, hub is always active (default)
@@ -171,7 +173,7 @@ export interface PreExistingCollectionConfig {
libraryName: string; // Library display name
mediaType: 'movie' | 'tv'; // Media type based on library type
titleSort?: string; // Plex sortTitle field for alphabetical ordering
sortOrderHome: number; // Position on Plex home screen
sortOrderHome: number; // Position on Plex home screen (1+ for positioned items, 0 for void)
sortOrderLibrary: number; // Position in library (0 for A-Z section, 1+ for promoted section)
isLibraryPromoted: boolean; // true = promoted section (uses exclamation marks), false = A-Z section
visibilityConfig: {
@@ -190,6 +192,7 @@ export interface PreExistingCollectionConfig {
isLinked?: boolean; // True if collection is actively linked to other collections (set by backend linking logic)
linkId?: number; // Group ID for linked collections (set by backend linking logic)
isUnlinked?: boolean; // True if this collection was deliberately unlinked and should not be grouped with siblings
everLibraryPromoted?: boolean; // True if this collection has ever been promoted to the promoted section (once true, stays true until sortTitle reset)
// Time restriction settings
readonly timeRestriction?: {
readonly alwaysActive: boolean; // If true, collection is always active (default)

View File

@@ -424,7 +424,7 @@ collectionsRoutes.delete('/:id', isAuthenticated(), async (req, res) => {
configsByLibrary.forEach((libraryConfigs) => {
// Sort by current sortOrderHome to maintain relative order
libraryConfigs.sort(
(a, b) => (a.sortOrderHome || 0) - (b.sortOrderHome || 0)
(a, b) => (a.sortOrderHome || 1) - (b.sortOrderHome || 1)
);
// Reassign sequential sort orders
@@ -590,8 +590,18 @@ collectionsRoutes.post('/create', isAuthenticated(), async (req, res) => {
statType: req.body.tautulliStatType,
subtype: req.body.subtype,
};
// For custom templates, choose the appropriate template based on library type
let templateToProcess = req.body.template || req.body.name || '';
if (req.body.template === 'custom') {
if (libraryMediaType === 'movie' && req.body.customMovieTemplate) {
templateToProcess = req.body.customMovieTemplate;
} else if (libraryMediaType === 'tv' && req.body.customTVTemplate) {
templateToProcess = req.body.customTVTemplate;
}
}
let processedName = templateEngine.processTemplate(
req.body.template || req.body.name || '',
templateToProcess,
context
);
@@ -628,6 +638,7 @@ collectionsRoutes.post('/create', isAuthenticated(), async (req, res) => {
isLinked: libraryIds.length > 1,
linkId: linkId,
isLibraryPromoted: true, // All new Agregarr collections start in promoted section
everLibraryPromoted: true, // New collections start promoted, so mark as ever promoted
// Remove multi-library fields that don't belong in individual configs
libraryIds: undefined,
libraryNames: undefined,

View File

@@ -265,6 +265,7 @@ preExistingRoutes.patch('/:id/promote', isAuthenticated(), async (req, res) => {
const finalConfig = preExistingCollectionConfigService.updateSettings(id, {
isLibraryPromoted: true,
sortOrderLibrary: maxSortOrder + 1,
everLibraryPromoted: true, // Mark as ever promoted when promoting
});
// Mark pre-existing collection as needing sync due to promotion
@@ -320,10 +321,11 @@ preExistingRoutes.patch('/:id/demote', isAuthenticated(), async (req, res) => {
.json({ error: 'Collection is already in A-Z section' });
}
// Update in service
// Update in service - Keep everLibraryPromoted: true when demoting
const finalConfig = preExistingCollectionConfigService.updateSettings(id, {
isLibraryPromoted: false,
sortOrderLibrary: 0, // A-Z collections have sortOrderLibrary: 0
// Note: everLibraryPromoted stays true when demoting - will be reset to false during sync after sortTitle cleanup
});
// Mark pre-existing collection as needing sync due to demotion

View File

@@ -200,18 +200,33 @@ async function handleManualReordering(
// Strip metadata and add sort order
const { configType, ...originalConfig } = item;
// Calculate sort order with special handling for promoted collections in Library context
// Apply correct sort order logic based on context and collection type
let sortOrder = index;
if (
sortOrderField === 'sortOrderLibrary' &&
originalConfig.isLibraryPromoted
) {
// Promoted collections in Library context need to start from 1, not 0
// For Library context, respect the A-Z vs Promoted section design
if (sortOrderField === 'sortOrderLibrary') {
if (originalConfig.isLibraryPromoted === false) {
sortOrder = 0; // A-Z section always gets 0
} else {
sortOrder = index + 1; // Promoted section starts from 1
}
} else {
// For Home/Recommended contexts, start from 1 (0 is void value)
sortOrder = index + 1;
}
const updatedConfig = { ...originalConfig, [sortOrderField]: sortOrder };
// Set everLibraryPromoted: true when a collection is assigned to the promoted library section
if (
sortOrderField === 'sortOrderLibrary' &&
originalConfig.isLibraryPromoted === true &&
sortOrder > 0 &&
configType === 'collection'
) {
updatedConfig.everLibraryPromoted = true;
}
// Use type guards to ensure config matches declared type
if (configType === 'collection' && isCollectionConfig(updatedConfig)) {
collectionsToUpdate.push(updatedConfig);
@@ -464,11 +479,41 @@ async function performAutoReordering(
return (aSortOrder as number) - (bSortOrder as number);
});
// Assign sequential sort orders (0, 1, 2, 3...)
const updatedItems = allLibraryItems.map((item, index) => ({
...item,
[sortOrderField]: index,
}));
// Assign sequential sort orders with proper A-Z vs Promoted section logic
const updatedItems = allLibraryItems.map((item, index) => {
let newSortOrder = index;
// For Library context, respect the A-Z vs Promoted section design:
// - A-Z section (isLibraryPromoted: false) → sortOrderLibrary: 0
// - Promoted section (isLibraryPromoted: true) → sortOrderLibrary: 1, 2, 3...
if (sortOrderField === 'sortOrderLibrary') {
if (item.isLibraryPromoted === false) {
newSortOrder = 0; // A-Z section always gets 0
} else {
newSortOrder = index + 1; // Promoted section starts from 1
}
} else {
// For Home/Recommended contexts, start from 1 (0 is void value)
newSortOrder = index + 1;
}
const updatedItem = {
...item,
[sortOrderField]: newSortOrder,
};
// Set everLibraryPromoted: true when a collection is assigned to the promoted library section
if (
sortOrderField === 'sortOrderLibrary' &&
item.isLibraryPromoted === true &&
newSortOrder > 0 &&
item.configType === 'collection'
) {
updatedItem.everLibraryPromoted = true;
}
return updatedItem;
});
// Apply updates back to their respective services
let totalUpdated = 0;

View File

@@ -1372,12 +1372,16 @@ const CollectionFormConfigForm = ({
libraryId: values.libraryId as string,
libraryName: values.libraryName as string,
name: generateCollectionName(values as CollectionFormConfig),
// For custom templates, send the actual custom text as the template
template:
// For custom templates, pass both custom templates and let backend choose
template: values.template,
customMovieTemplate:
values.template === 'custom'
? (values as CollectionFormConfig).customMovieTemplate ||
(values as CollectionFormConfig).customTVTemplate
: values.template,
? (values as CollectionFormConfig).customMovieTemplate
: undefined,
customTVTemplate:
values.template === 'custom'
? (values as CollectionFormConfig).customTVTemplate
: undefined,
// Convert string numbers to integers
customDays: values.customDays
? parseInt(values.customDays.toString(), 10)

View File

@@ -534,8 +534,8 @@ const CollectionSettings = ({
maxItems: 30,
libraryId: '', // Start with no selection to show "Select Libraries..."
libraryName: '',
sortOrderHome: 0, // Default to top of home screen
sortOrderLibrary: 0, // Default to top of library tab
sortOrderHome: 1, // Default positioned item (0 is void)
sortOrderLibrary: 1, // Default promoted section (0 is A-Z)
customDays: 30, // Default for Tautulli collections
tautulliStatType: 'plays', // Default stat type
searchMissingMovies: false,

View File

@@ -154,7 +154,7 @@ export const useCollectionEdit = () => {
: hubConfig.mediaType === 'tv'
? 'tv'
: 'both',
sortOrderHome: hubConfig.sortOrderHome || 0,
sortOrderHome: hubConfig.sortOrderHome || 1,
sortOrderLibrary: hubConfig.sortOrderLibrary,
visibilityConfig: hubConfig.visibilityConfig,
isDefaultPlexHub: hubConfig.isDefaultPlexHub,

View File

@@ -35,6 +35,7 @@ export interface PlexHubConfig {
isLinked?: boolean; // True if hub is actively linked to other hubs (set by backend linking logic)
linkId?: number; // Group ID for linked hubs (set by backend linking logic)
isUnlinked?: boolean; // True if this hub was deliberately unlinked and should not be grouped with siblings
everLibraryPromoted?: boolean; // True if this hub has ever been promoted to the promoted section (once true, stays true until sortTitle reset)
// Time restriction settings - all hub types can have time restrictions
timeRestriction?: {
alwaysActive: boolean; // If true, hub is always active (default)
@@ -87,6 +88,7 @@ export interface PreExistingCollectionConfig {
isLinked?: boolean; // True if collection is actively linked to other collections (set by backend linking logic)
linkId?: number; // Group ID for linked collections (set by backend linking logic)
isUnlinked?: boolean; // True if this collection was deliberately unlinked and should not be grouped with siblings
everLibraryPromoted?: boolean; // True if this collection has ever been promoted to the promoted section (once true, stays true until sortTitle reset)
// Time restriction settings
timeRestriction?: {
alwaysActive: boolean; // If true, collection is always active (default)
@@ -217,6 +219,7 @@ export interface CollectionFormConfig {
// Backend properties (from PlexHubConfig) - Present on hub configs from API
readonly collectionType?: CollectionType; // Simplified categorization system
readonly isUnlinked?: boolean; // True if this hub was deliberately unlinked
everLibraryPromoted?: boolean; // True if this collection has ever been promoted to the promoted section (once true, stays true until sortTitle reset)
// Hub-specific properties (present when config represents a hub)
readonly hubIdentifier?: string; // Plex hub identifier (e.g., "movie.recentlyadded")

View File

@@ -47,11 +47,11 @@ export function groupConfigsByLibrary(
let bSortOrder: number;
if (activeTab === 'library') {
aSortOrder = a.sortOrderLibrary ?? 0;
bSortOrder = b.sortOrderLibrary ?? 0;
aSortOrder = a.sortOrderLibrary ?? 0; // Keep 0 for A-Z section
bSortOrder = b.sortOrderLibrary ?? 0; // Keep 0 for A-Z section
} else {
aSortOrder = a.sortOrderHome ?? 0;
bSortOrder = b.sortOrderHome ?? 0;
aSortOrder = a.sortOrderHome ?? 1; // 1+ for positioned, 0 for void
bSortOrder = b.sortOrderHome ?? 1; // 1+ for positioned, 0 for void
}
return aSortOrder - bSortOrder;
@@ -112,8 +112,8 @@ export function normalizeConfigsForStorage(
const normalized = configs.map((config) => ({
...config,
// Ensure sort orders are properly set
sortOrderHome: config.sortOrderHome ?? 0,
sortOrderLibrary: config.sortOrderLibrary ?? 0,
sortOrderHome: config.sortOrderHome ?? 1, // 1+ for positioned, 0 for void
sortOrderLibrary: config.sortOrderLibrary ?? 0, // Keep 0 for A-Z section
}));
return normalized;