Add ${var} delimiter syntax for path templates

Users can now append literal text to template variables using curly
braces: ${albumtype}s produces "Albums", "Singles", "EPs". Without
braces, $albumtypes was rejected as an unknown variable by validation.

Both syntaxes work: $albumtype (plain) and ${albumtype} (delimited).
Bracket vars are resolved first to prevent partial matching conflicts.
Validation updated for album, single, and playlist templates.
This commit is contained in:
Broque Thomas 2026-04-20 18:03:11 -07:00
parent c3f88a713a
commit 93e036848b
3 changed files with 39 additions and 16 deletions

View file

@ -18498,6 +18498,25 @@ def _apply_path_template(template: str, context: dict) -> str:
album_artist_value = resolved album_artist_value = resolved
except Exception: except Exception:
pass pass
# Support ${var} delimited syntax (e.g. ${albumtype}s → Albums)
# Must run before $var replacements to prevent partial matching
_bracket_map = {
'albumartist': album_artist_value,
'albumtype': clean_context.get('albumtype', 'Album'),
'playlist': clean_context.get('playlist_name', ''),
'artistletter': (clean_context.get('artist', 'U') or 'U')[0].upper(),
'artist': clean_context.get('artist', 'Unknown Artist'),
'album': clean_context.get('album', 'Unknown Album'),
'title': clean_context.get('title', 'Unknown Track'),
'track': f"{clean_context.get('track_number', 1):02d}",
'disc': str(clean_context.get('disc_number', 1)),
'discnum': str(clean_context.get('disc_number', 1)),
'year': str(clean_context.get('year', '')),
'quality': clean_context.get('quality', ''),
}
for var_name, val in _bracket_map.items():
result = result.replace('${' + var_name + '}', val)
result = result.replace('$albumartist', album_artist_value) result = result.replace('$albumartist', album_artist_value)
result = result.replace('$albumtype', clean_context.get('albumtype', 'Album')) result = result.replace('$albumtype', clean_context.get('albumtype', 'Album'))
result = result.replace('$playlist', clean_context.get('playlist_name', '')) result = result.replace('$playlist', clean_context.get('playlist_name', ''))

View file

@ -5068,7 +5068,7 @@
<input type="text" id="template-album-path" <input type="text" id="template-album-path"
placeholder="$albumartist/$albumartist - $album/$track - $title"> placeholder="$albumartist/$albumartist - $album/$track - $title">
<small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $album, $albumtype, <small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $album, $albumtype,
$title, $track, $disc (01), $discnum (1), $year, $quality (filename only)</small> $title, $track, $disc (01), $discnum (1), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums</small>
</div> </div>
<div class="form-group"> <div class="form-group">

View file

@ -5703,17 +5703,19 @@ function validateFileOrganizationTemplates() {
errors.push('Album template cannot have consecutive slashes //'); errors.push('Album template cannot have consecutive slashes //');
} }
// Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.) // Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.)
const albumVarPattern = /\$[a-zA-Z]+/g; const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = albumPath.match(albumVarPattern) || []; const foundVars = albumPath.match(albumVarPattern) || [];
foundVars.forEach(v => { foundVars.forEach(v => {
const lowerVar = v.toLowerCase(); // Normalize ${var} to $var for validation
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
const lowerVar = normalized.toLowerCase();
// Check if lowercase version exists in valid vars // Check if lowercase version exists in valid vars
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar); const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) { if (!isValid) {
errors.push(`Invalid variable "${v}" in album template. Valid: ${validVars.album.join(', ')}`); errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`);
} else if (v !== lowerVar && validVars.album.includes(lowerVar)) { } else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) {
// Variable is valid but has wrong case // Variable is valid but has wrong case
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`); errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
} }
}); });
} }
@ -5730,15 +5732,16 @@ function validateFileOrganizationTemplates() {
if (singlePath.includes('//')) { if (singlePath.includes('//')) {
errors.push('Single template cannot have consecutive slashes //'); errors.push('Single template cannot have consecutive slashes //');
} }
const singleVarPattern = /\$[a-zA-Z]+/g; const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = singlePath.match(singleVarPattern) || []; const foundVars = singlePath.match(singleVarPattern) || [];
foundVars.forEach(v => { foundVars.forEach(v => {
const lowerVar = v.toLowerCase(); const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
const lowerVar = normalized.toLowerCase();
const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar); const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) { if (!isValid) {
errors.push(`Invalid variable "${v}" in single template. Valid: ${validVars.single.join(', ')}`); errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`);
} else if (v !== lowerVar && validVars.single.includes(lowerVar)) { } else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) {
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`); errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
} }
}); });
} }
@ -5757,15 +5760,16 @@ function validateFileOrganizationTemplates() {
if (playlistPath.includes('//')) { if (playlistPath.includes('//')) {
errors.push('Playlist template cannot have consecutive slashes //'); errors.push('Playlist template cannot have consecutive slashes //');
} }
const playlistVarPattern = /\$[a-zA-Z]+/g; const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = playlistPath.match(playlistVarPattern) || []; const foundVars = playlistPath.match(playlistVarPattern) || [];
foundVars.forEach(v => { foundVars.forEach(v => {
const lowerVar = v.toLowerCase(); const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
const lowerVar = normalized.toLowerCase();
const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar); const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) { if (!isValid) {
errors.push(`Invalid variable "${v}" in playlist template. Valid: ${validVars.playlist.join(', ')}`); errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
} else if (v !== lowerVar && validVars.playlist.includes(lowerVar)) { } else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) {
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`); errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
} }
}); });
} }