From 93e036848b6a3e41acbdf25166e7972018e45d0b Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 20 Apr 2026 18:03:11 -0700
Subject: [PATCH] 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.
---
web_server.py | 19 +++++++++++++++++++
webui/index.html | 2 +-
webui/static/script.js | 34 +++++++++++++++++++---------------
3 files changed, 39 insertions(+), 16 deletions(-)
diff --git a/web_server.py b/web_server.py
index d888fda3..12665afb 100644
--- a/web_server.py
+++ b/web_server.py
@@ -18498,6 +18498,25 @@ def _apply_path_template(template: str, context: dict) -> str:
album_artist_value = resolved
except Exception:
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('$albumtype', clean_context.get('albumtype', 'Album'))
result = result.replace('$playlist', clean_context.get('playlist_name', ''))
diff --git a/webui/index.html b/webui/index.html
index f1c63959..fcca3368 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -5068,7 +5068,7 @@
Variables: $albumartist, $artist, $artistletter, $album, $albumtype,
- $title, $track, $disc (01), $discnum (1), $year, $quality (filename only)
+ $title, $track, $disc (01), $discnum (1), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums
diff --git a/webui/static/script.js b/webui/static/script.js
index 8b7cdc96..e9a3aacc 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -5703,17 +5703,19 @@ function validateFileOrganizationTemplates() {
errors.push('Album template cannot have consecutive slashes //');
}
// 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) || [];
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
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
- errors.push(`Invalid variable "${v}" in album template. Valid: ${validVars.album.join(', ')}`);
- } else if (v !== lowerVar && validVars.album.includes(lowerVar)) {
+ errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`);
+ } else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) {
// 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('//')) {
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) || [];
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);
if (!isValid) {
- errors.push(`Invalid variable "${v}" in single template. Valid: ${validVars.single.join(', ')}`);
- } else if (v !== lowerVar && validVars.single.includes(lowerVar)) {
- errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
+ errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`);
+ } else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) {
+ errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
@@ -5757,15 +5760,16 @@ function validateFileOrganizationTemplates() {
if (playlistPath.includes('//')) {
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) || [];
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);
if (!isValid) {
- errors.push(`Invalid variable "${v}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
- } else if (v !== lowerVar && validVars.playlist.includes(lowerVar)) {
- errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
+ errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
+ } else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) {
+ errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}