fix: custom title not propogating to UI on save, fix linked editing

fixes #29, fixes #37
This commit is contained in:
Tom Wheeler
2025-08-31 00:57:00 +12:00
parent ee3d27f1d5
commit 78c45c9905
7 changed files with 291 additions and 214 deletions
+24 -11
View File
@@ -1,4 +1,4 @@
name: Agregarr Latest
name: Agregarr Release
on:
push:
@@ -6,12 +6,18 @@ on:
- latest
jobs:
build_and_push:
name: Build & Publish Docker Images
semantic-release:
name: Tag and release latest version
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
@@ -21,12 +27,19 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
agregarr/agregarr:latest
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
env:
HUSKY: 0
run: yarn
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_TOKEN }}
run: npx semantic-release
+1 -1
View File
@@ -229,7 +229,7 @@
]
],
"branches": [
"master"
"latest"
],
"npmPublish": false,
"publish": [
@@ -109,6 +109,7 @@ export class DefaultHubConfigService {
/**
* Update settings for an individual default hub configuration
* Preserves computed fields while allowing user changes
* If the hub is linked, updates all linked hub instances
*/
public updateSettings(
id: string,
@@ -123,32 +124,65 @@ export class DefaultHubConfigService {
const existingConfig = configs[existingConfigIndex];
// Merge settings while preserving computed fields
const updatedConfig: PlexHubConfig = {
...existingConfig, // Preserve all existing fields including computed ones
...settings, // Apply user changes
// Ensure computed fields stay computed:
id: existingConfig.id, // ID never changes
isActive: existingConfig.isActive, // isActive is computed elsewhere
collectionType: existingConfig.collectionType, // Computed field
// Business logic fields can be changed by user:
isLinked: settings.isLinked ?? existingConfig.isLinked,
linkId: settings.linkId ?? existingConfig.linkId,
};
// Check if this is a linked hub - if so, update all linked configs
const configsToUpdate = [];
if (existingConfig.isLinked && existingConfig.linkId) {
// Find all configs with the same linkId
const linkedConfigs = configs.filter(
(c) => c.linkId === existingConfig.linkId && c.isLinked
);
configsToUpdate.push(...linkedConfigs);
logger.info(`Updating ${linkedConfigs.length} linked hub configs`, {
label: 'Default Hub Config Service',
linkId: existingConfig.linkId,
configIds: linkedConfigs.map((c) => c.id),
});
} else {
configsToUpdate.push(existingConfig);
}
// Update the config in place
configs[existingConfigIndex] = updatedConfig;
const updatedConfigs: PlexHubConfig[] = [];
// Process each config (could be just one, or multiple if linked)
for (const configToUpdate of configsToUpdate) {
const configIndex = configs.findIndex((c) => c.id === configToUpdate.id);
// Merge settings while preserving computed fields and library-specific fields
const updatedConfig: PlexHubConfig = {
...configToUpdate, // Preserve all existing fields including computed ones
...settings, // Apply user changes
// Ensure computed fields stay computed:
id: configToUpdate.id, // ID never changes
isActive: configToUpdate.isActive, // isActive is computed elsewhere
collectionType: configToUpdate.collectionType, // Computed field
// 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
hubIdentifier: configToUpdate.hubIdentifier, // Don't change the hub identifier
mediaType: configToUpdate.mediaType, // Don't change the media type
// Business logic fields can be changed by user:
isLinked: settings.isLinked ?? configToUpdate.isLinked,
linkId: settings.linkId ?? configToUpdate.linkId,
};
// Update the config in place
configs[configIndex] = updatedConfig;
updatedConfigs.push(updatedConfig);
}
// Save the updated configs
this.saveExistingConfigs(configs);
logger.info('Individual default hub config updated successfully', {
logger.info('Hub config(s) updated successfully', {
label: 'Default Hub Config Service',
configId: id,
configName: updatedConfig.name,
updatedCount: updatedConfigs.length,
configIds: updatedConfigs.map((c) => c.id),
configNames: updatedConfigs.map((c) => c.name),
isLinked: existingConfig.isLinked,
linkId: existingConfig.linkId || 'none',
});
return updatedConfig;
return updatedConfigs[0]; // Return the primary config (the one that was edited)
}
/**
@@ -133,6 +133,7 @@ export class PreExistingCollectionConfigService {
/**
* Update settings for an individual pre-existing collection configuration
* Preserves computed fields while allowing user changes
* If the collection is linked, updates all linked collection instances
*/
public updateSettings(
id: string,
@@ -147,36 +148,69 @@ export class PreExistingCollectionConfigService {
const existingConfig = configs[existingConfigIndex];
// Merge settings while preserving computed fields
const updatedConfig: PreExistingCollectionConfig = {
...existingConfig, // Preserve all existing fields including computed ones
...settings, // Apply user changes
// Ensure computed fields stay computed:
id: existingConfig.id, // ID never changes
isActive: existingConfig.isActive, // isActive is computed elsewhere
collectionType: existingConfig.collectionType, // Computed field
// Business logic fields can be changed by user:
isLinked: settings.isLinked ?? existingConfig.isLinked,
linkId: settings.linkId ?? existingConfig.linkId,
isUnlinked: settings.isUnlinked ?? existingConfig.isUnlinked,
};
// Check if this is a linked collection - if so, update all linked configs
const configsToUpdate = [];
if (existingConfig.isLinked && existingConfig.linkId) {
// Find all configs with the same linkId
const linkedConfigs = configs.filter(
(c) => c.linkId === existingConfig.linkId && c.isLinked
);
configsToUpdate.push(...linkedConfigs);
logger.info(
`Updating ${linkedConfigs.length} linked pre-existing collection configs`,
{
label: 'Pre-existing Collection Config Service',
linkId: existingConfig.linkId,
configIds: linkedConfigs.map((c) => c.id),
}
);
} else {
configsToUpdate.push(existingConfig);
}
// Update the config in place
configs[existingConfigIndex] = updatedConfig;
const updatedConfigs: PreExistingCollectionConfig[] = [];
// Process each config (could be just one, or multiple if linked)
for (const configToUpdate of configsToUpdate) {
const configIndex = configs.findIndex((c) => c.id === configToUpdate.id);
// Merge settings while preserving computed fields and library-specific fields
const updatedConfig: PreExistingCollectionConfig = {
...configToUpdate, // Preserve all existing fields including computed ones
...settings, // Apply user changes
// Ensure computed fields stay computed:
id: configToUpdate.id, // ID never changes
isActive: configToUpdate.isActive, // isActive is computed elsewhere
collectionType: configToUpdate.collectionType, // Computed field
// 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
collectionRatingKey: configToUpdate.collectionRatingKey, // Don't change the Plex rating key
mediaType: configToUpdate.mediaType, // Don't change the media type
// Business logic fields can be changed by user:
isLinked: settings.isLinked ?? configToUpdate.isLinked,
linkId: settings.linkId ?? configToUpdate.linkId,
isUnlinked: settings.isUnlinked ?? configToUpdate.isUnlinked,
};
// Update the config in place
configs[configIndex] = updatedConfig;
updatedConfigs.push(updatedConfig);
}
// Save the updated configs
this.saveExistingConfigs(configs);
logger.info(
'Individual pre-existing collection config updated successfully',
{
label: 'Pre-existing Collection Config Service',
configId: id,
configName: updatedConfig.name,
}
);
logger.info('Pre-existing collection config(s) updated successfully', {
label: 'Pre-existing Collection Config Service',
updatedCount: updatedConfigs.length,
configIds: updatedConfigs.map((c) => c.id),
configNames: updatedConfigs.map((c) => c.name),
isLinked: existingConfig.isLinked,
linkId: existingConfig.linkId || 'none',
});
return updatedConfig;
return updatedConfigs[0]; // Return the primary config (the one that was edited)
}
/**
+129 -81
View File
@@ -171,104 +171,152 @@ collectionsRoutes.put('/:id/settings', isAuthenticated(), async (req, res) => {
const existingConfig = configs[existingConfigIndex];
// Process template to generate actual collection name (same logic as create endpoint)
// Get library to determine media type
const libraries = settings.plex.libraries || [];
const library = libraries.find(
(lib) => lib.key === (req.body.libraryId || existingConfig.libraryId)
);
const libraryMediaType: 'movie' | 'tv' =
library && library.type === 'show' ? 'tv' : 'movie';
const context = {
...templateEngine.getDefaultContext(),
mediaType: libraryMediaType,
days: req.body.customDays || existingConfig.customDays,
customdays: req.body.customDays || existingConfig.customDays,
statType: req.body.tautulliStatType || existingConfig.tautulliStatType,
subtype: req.body.subtype || existingConfig.subtype,
};
let processedName = templateEngine.processTemplate(
req.body.template ||
req.body.name ||
existingConfig.template ||
existingConfig.name ||
'',
context
);
// For Overseerr user collections, keep {username} and {nickname} as literals
if (
(req.body.type || existingConfig.type) === 'overseerr' &&
(req.body.subtype || existingConfig.subtype) === 'users'
) {
const defaultContext = templateEngine.getDefaultContext();
if (defaultContext.username) {
processedName = processedName.replace(
new RegExp(defaultContext.username, 'g'),
'{username}'
);
}
if (defaultContext.nickname) {
processedName = processedName.replace(
new RegExp(defaultContext.nickname, 'g'),
'{nickname}'
);
}
// Check if this is a linked collection - if so, update all linked configs
const configsToUpdate = [];
if (existingConfig.isLinked && existingConfig.linkId) {
// Find all configs with the same linkId
const linkedConfigs = configs.filter(
(c) => c.linkId === existingConfig.linkId && c.isLinked
);
configsToUpdate.push(...linkedConfigs);
logger.info(
`Updating ${linkedConfigs.length} linked collection configs`,
{
label: 'Collections API',
linkId: existingConfig.linkId,
configIds: linkedConfigs.map((c) => c.id),
}
);
} else {
configsToUpdate.push(existingConfig);
}
// Merge settings while preserving computed fields
const updatedConfig = {
...existingConfig, // Preserve all existing fields including computed ones
...req.body, // Apply user changes
name: processedName, // Use processed template name
// Ensure computed fields stay computed:
id: existingConfig.id, // ID never changes
isActive: existingConfig.isActive, // Preserve sync service's isActive calculation
};
// Get libraries for template processing
const libraries = settings.plex.libraries || [];
const updatedConfigs: CollectionConfig[] = [];
const affectedLibraryIds: string[] = [];
// Process each config (could be just one, or multiple if linked)
for (const configToUpdate of configsToUpdate) {
const configIndex = configs.findIndex((c) => c.id === configToUpdate.id);
// Get library to determine media type for template processing
const library = libraries.find(
(lib) => lib.key === (req.body.libraryId || configToUpdate.libraryId)
);
const libraryMediaType: 'movie' | 'tv' =
library && library.type === 'show' ? 'tv' : 'movie';
const context = {
...templateEngine.getDefaultContext(),
mediaType: libraryMediaType,
days: req.body.customDays || configToUpdate.customDays,
customdays: req.body.customDays || configToUpdate.customDays,
statType: req.body.tautulliStatType || configToUpdate.tautulliStatType,
subtype: req.body.subtype || configToUpdate.subtype,
};
let processedName = templateEngine.processTemplate(
req.body.template ||
req.body.name ||
configToUpdate.template ||
configToUpdate.name ||
'',
context
);
// For Overseerr user collections, keep {username} and {nickname} as literals
if (
(req.body.type || configToUpdate.type) === 'overseerr' &&
(req.body.subtype || configToUpdate.subtype) === 'users'
) {
const defaultContext = templateEngine.getDefaultContext();
if (defaultContext.username) {
processedName = processedName.replace(
new RegExp(defaultContext.username, 'g'),
'{username}'
);
}
if (defaultContext.nickname) {
processedName = processedName.replace(
new RegExp(defaultContext.nickname, 'g'),
'{nickname}'
);
}
}
// Merge settings while preserving computed fields and library-specific fields
const updatedConfig: CollectionConfig = {
...configToUpdate, // Preserve all existing fields including computed ones
...req.body, // Apply user changes
name: processedName, // Use processed template name
// Ensure computed fields stay computed:
id: configToUpdate.id, // ID never changes
isActive: configToUpdate.isActive, // Preserve sync service's isActive calculation
// 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
};
// Update the config in place
configs[configIndex] = updatedConfig;
updatedConfigs.push(updatedConfig);
// Track affected libraries for auto-reorder
const libraryId = Array.isArray(updatedConfig.libraryId)
? updatedConfig.libraryId[0]
: updatedConfig.libraryId;
if (libraryId && !affectedLibraryIds.includes(libraryId)) {
affectedLibraryIds.push(libraryId);
}
// Mark collection as needing sync due to modification
settings.markCollectionModified(configToUpdate.id, 'collection');
}
// Update the config in place
configs[existingConfigIndex] = updatedConfig;
settings.plex.collectionConfigs = configs;
settings.save();
// Mark collection as needing sync due to modification
settings.markCollectionModified(id, 'collection');
logger.info('Individual collection config updated successfully', {
logger.info('Collection config(s) updated successfully', {
label: 'Collections API',
configId: id,
configName: updatedConfig.name,
updatedCount: updatedConfigs.length,
configIds: updatedConfigs.map((c) => c.id),
configNames: updatedConfigs.map((c) => c.name),
isLinked: existingConfig.isLinked,
linkId: existingConfig.linkId || 'none',
});
// Auto-reorder after visibility changes to assign proper sort orders
const { autoReorderLibrary } = await import('@server/routes/reorder');
try {
const libraryId = Array.isArray(updatedConfig.libraryId)
? updatedConfig.libraryId[0]
: updatedConfig.libraryId;
await autoReorderLibrary(libraryId, 'home');
await autoReorderLibrary(libraryId, 'library');
logger.debug(
`Auto-reordering completed after collection settings update for library ${libraryId}`,
{
for (const libraryId of affectedLibraryIds) {
try {
await autoReorderLibrary(libraryId, 'home');
await autoReorderLibrary(libraryId, 'library');
logger.debug(
`Auto-reordering completed after collection settings update for library ${libraryId}`,
{
label: 'Collections API - Auto Reorder',
}
);
} catch (error) {
logger.warn('Failed to auto-reorder after collection settings update', {
label: 'Collections API - Auto Reorder',
}
);
} catch (error) {
logger.warn('Failed to auto-reorder after collection settings update', {
label: 'Collections API - Auto Reorder',
error: error instanceof Error ? error.message : String(error),
});
// Don't fail the settings update if reordering fails
libraryId,
error: error instanceof Error ? error.message : String(error),
});
// Don't fail the settings update if reordering fails
}
}
return res.status(200).json({
collectionConfig: updatedConfig,
message: 'Collection settings updated successfully',
collectionConfig: updatedConfigs[0], // Return the primary config (the one that was edited)
updatedConfigs: updatedConfigs, // Include all updated configs in response
message: `${updatedConfigs.length} collection config${
updatedConfigs.length === 1 ? '' : 's'
} updated successfully`,
});
} catch (error) {
logger.error('Failed to update individual collection settings', {
logger.error('Failed to update collection settings', {
label: 'Collections API',
error: error instanceof Error ? error.message : String(error),
configId: req.params.id,
@@ -1383,8 +1383,12 @@ const CollectionFormConfigForm = ({
libraryId: values.libraryId as string,
libraryName: values.libraryName as string,
name: generateCollectionName(values as CollectionFormConfig),
// For custom templates, pass both custom templates and let backend choose
template: values.template,
// For custom templates, send the actual custom text as the template
template:
values.template === 'custom'
? (values as CollectionFormConfig).customMovieTemplate ||
(values as CollectionFormConfig).customTVTemplate
: values.template,
customMovieTemplate:
values.template === 'custom'
? (values as CollectionFormConfig).customMovieTemplate
@@ -2074,6 +2078,16 @@ const CollectionFormConfigForm = ({
return values.name || 'User Collection';
}
// Handle custom templates - use the actual custom template text, not "custom"
if (values.template === 'custom') {
// Use the first available custom template (movie or TV)
const customTemplate =
values.customMovieTemplate || values.customTVTemplate;
if (customTemplate) {
return customTemplate;
}
}
// Return the template as the name - backend will process it with proper library context
return values.template || values.name || 'Collection';
}
@@ -1436,81 +1436,13 @@ const CollectionSettings = ({
let changedConfigs: CollectionFormConfig[] = [];
if (existingIndex >= 0) {
// Update existing config
if (
collectionConfig.isLinked &&
collectionConfig.linkId &&
collectionConfig.libraryIds
) {
// This is a linked collection update - update all linked configs with same type/subtype
updatedConfigs = [...localCollectionConfigs];
// Update existing config - backend will handle linked collection propagation
updatedConfigs = [...localCollectionConfigs];
updatedConfigs[existingIndex] = collectionConfig;
// Find all linked configs with same type/subtype and linkId
const linkedConfigs = updatedConfigs.filter(
(c) =>
c.type === collectionConfig.type &&
c.subtype === collectionConfig.subtype &&
c.linkId === collectionConfig.linkId &&
c.isLinked // Only linked configs
);
// Update all linked configs with new settings (except library-specific fields)
linkedConfigs.forEach((linkedConfig) => {
const index = updatedConfigs.findIndex(
(c) => c.id === linkedConfig.id
);
if (index >= 0) {
updatedConfigs[index] = {
...linkedConfig,
// Update shared settings
template: collectionConfig.template,
customMovieTemplate: collectionConfig.customMovieTemplate,
customTVTemplate: collectionConfig.customTVTemplate,
visibilityConfig: collectionConfig.visibilityConfig,
maxItems: collectionConfig.maxItems,
mediaType: collectionConfig.mediaType,
customDays: collectionConfig.customDays,
tautulliStatType: collectionConfig.tautulliStatType,
downloadMode: collectionConfig.downloadMode,
searchMissingMovies: collectionConfig.searchMissingMovies,
searchMissingTV: collectionConfig.searchMissingTV,
autoApproveMovies: collectionConfig.autoApproveMovies,
autoApproveTV: collectionConfig.autoApproveTV,
maxSeasonsToRequest: collectionConfig.maxSeasonsToRequest,
maxPositionToProcess: collectionConfig.maxPositionToProcess,
traktCustomListUrl: collectionConfig.traktCustomListUrl,
tmdbCustomListUrl: collectionConfig.tmdbCustomListUrl,
imdbCustomListUrl: collectionConfig.imdbCustomListUrl,
letterboxdCustomListUrl:
collectionConfig.letterboxdCustomListUrl,
reverseOrder: collectionConfig.reverseOrder,
randomizeOrder: collectionConfig.randomizeOrder,
timeRestriction: collectionConfig.timeRestriction,
customPoster: collectionConfig.customPoster,
isUnlinked: collectionConfig.isUnlinked,
// Keep library-specific fields unchanged
libraryId: linkedConfig.libraryId,
libraryName: linkedConfig.libraryName,
collectionRatingKey: linkedConfig.collectionRatingKey,
};
}
});
// Only send API calls for the linked configs that were actually changed
changedConfigs = linkedConfigs.map((linkedConfig) => {
const index = updatedConfigs.findIndex(
(c) => c.id === linkedConfig.id
);
return updatedConfigs[index];
});
} else {
// Regular single config update
updatedConfigs = [...localCollectionConfigs];
updatedConfigs[existingIndex] = collectionConfig;
// Only send API call for the single config that changed
changedConfigs = [collectionConfig];
}
// Always send API call for only the single config that changed
// Backend will automatically propagate changes to linked configs
changedConfigs = [collectionConfig];
} else {
// Add new config(s) - Use new simplified backend API
try {
@@ -1570,13 +1502,15 @@ const CollectionSettings = ({
}
}
// Update local React state with all changes for UI
setLocalCollectionConfigs(updatedConfigs);
// Only send API calls for collections that actually changed
if (changedConfigs.length > 0) {
await saveCollectionConfigs(changedConfigs);
// Refresh data from backend - this will pick up any linked collection changes
// that the backend automatically propagated
revalidateAll();
} else {
// Update local React state if no API calls needed
setLocalCollectionConfigs(updatedConfigs);
}
setShowConfigForm(false);