From cfa4a0c59faab7db2f97af950c4ba74582acbd98 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 20 Feb 2026 22:53:15 -0800 Subject: [PATCH] Add $artistletter and multi-disc support Introduce $artistletter and $disc template variables across config, UI, and backend to support artist-first-letter tokens and multi-disc albums. Update web_server.py to include disc_number in template context, prefer user-controlled $disc in templates, and create configurable disc subfolders using a new file_organization.disc_label setting. Update example and active config, web UI to expose the new variable and disc label selector, and script.js to validate, load, and save the new settings and substitutions. --- config/config.example.json | 2 +- config/config.json | 2 +- web_server.py | 41 ++++++++++++++++++++++++++------------ webui/index.html | 17 ++++++++++++---- webui/static/script.js | 8 +++++--- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 24d8a23..e82f5be 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -48,7 +48,7 @@ }, "file_organization": { "enabled": true, - "_template_variables": "Available: $artist, $albumartist, $album, $title, $track, $year, $playlist, $quality (filename only)", + "_template_variables": "Available: $artist, $albumartist, $artistletter, $album, $title, $track, $disc, $year, $playlist, $quality (filename only)", "templates": { "album_path": "$albumartist/$albumartist - $album/$track - $title", "single_path": "$artist/$artist - $title/$title", diff --git a/config/config.json b/config/config.json index b0699ef..8477983 100644 --- a/config/config.json +++ b/config/config.json @@ -48,7 +48,7 @@ }, "file_organization": { "enabled": true, - "_template_variables": "Available: $artist, $albumartist, $album, $title, $track, $year, $playlist, $quality (filename only)", + "_template_variables": "Available: $artist, $albumartist, $artistletter, $album, $title, $track, $disc, $year, $playlist, $quality (filename only)", "templates": { "album_path": "$albumartist/$albumartist - $album/$track - $title", "single_path": "$artist/$artist - $title/$title", diff --git a/web_server.py b/web_server.py index c2f7a51..3c0113d 100644 --- a/web_server.py +++ b/web_server.py @@ -8094,6 +8094,7 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): 'title': track_name, 'playlist_name': playlist_name, 'track_number': 1, + 'disc_number': 1, 'year': year, 'quality': context.get('_audio_quality', '') } @@ -8127,26 +8128,34 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): if track_number is None or not isinstance(track_number, int) or track_number < 1: track_number = 1 + # Multi-disc album subfolder support + disc_number = album_info.get('disc_number', 1) + template_context = { 'artist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name, 'albumartist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name, 'album': album_info['album_name'], 'title': clean_track_name, 'track_number': track_number, + 'disc_number': disc_number, 'year': year, 'quality': context.get('_audio_quality', '') } - - # Multi-disc album subfolder support - disc_number = album_info.get('disc_number', 1) spotify_album = context.get('spotify_album', {}) total_discs = spotify_album.get('total_discs', 1) if spotify_album else 1 + # Check if user controls disc structure via $disc in their template + album_template = config_manager.get('file_organization.templates.album_path', '') + user_controls_disc = '$disc' in album_template + + disc_label = config_manager.get('file_organization.disc_label', 'Disc') + folder_path, filename_base = _get_file_path_from_template(template_context, 'album_path') if folder_path and filename_base: - if total_discs > 1: - final_path = os.path.join(transfer_dir, folder_path, f"Disc {disc_number}", filename_base + file_ext) - os.makedirs(os.path.join(transfer_dir, folder_path, f"Disc {disc_number}"), exist_ok=True) + if total_discs > 1 and not user_controls_disc: + disc_folder = f"{disc_label} {disc_number}" + final_path = os.path.join(transfer_dir, folder_path, disc_folder, filename_base + file_ext) + os.makedirs(os.path.join(transfer_dir, folder_path, disc_folder), exist_ok=True) else: final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext) os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True) @@ -8159,7 +8168,7 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}" album_dir = os.path.join(artist_dir, album_folder_name) if total_discs > 1: - album_dir = os.path.join(album_dir, f"Disc {disc_number}") + album_dir = os.path.join(album_dir, f"{disc_label} {disc_number}") os.makedirs(album_dir, exist_ok=True) final_track_name_sanitized = _sanitize_filename(clean_track_name) new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}" @@ -8181,6 +8190,7 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): 'album': album_info.get('album_name', clean_track_name) if album_info else clean_track_name, 'title': clean_track_name, 'track_number': 1, + 'disc_number': 1, 'year': year, 'quality': context.get('_audio_quality', '') } @@ -8332,6 +8342,7 @@ def _apply_path_template(template: str, context: dict) -> str: result = result.replace('$playlist', clean_context.get('playlist_name', '')) # Medium length variables + result = result.replace('$artistletter', (clean_context.get('artist', 'U') or 'U')[0].upper()) result = result.replace('$artist', clean_context.get('artist', 'Unknown Artist')) result = result.replace('$album', clean_context.get('album', 'Unknown Album')) result = result.replace('$title', clean_context.get('title', 'Unknown Track')) @@ -8382,20 +8393,22 @@ def _get_file_path_from_template(context: dict, template_type: str = 'album_path # Split into folder and filename path_parts = full_path.split('/') - # Handle $quality: only substituted in the filename (last component). - # In folder components it becomes empty string to prevent album splits - # when tracks arrive in mixed qualities (e.g., FLAC 16bit vs 24bit). + # Handle $quality and $disc: only substituted in the filename (last component). + # In folder components they become empty string to prevent album splits + # when tracks arrive in mixed qualities or disc numbers in folder names. import re quality_value = context.get('quality', '') + disc_value = f"{context.get('disc_number', 1):02d}" if len(path_parts) > 1: folder_parts = path_parts[:-1] filename_base = path_parts[-1] - # Strip $quality from folder parts and clean up artifacts + # Strip $quality and $disc from folder parts and clean up artifacts cleaned_folders = [] for part in folder_parts: part = part.replace('$quality', '') + part = part.replace('$disc', '') part = re.sub(r'\s*\[\s*\]', '', part) # empty [] part = re.sub(r'\s*\(\s*\)', '', part) # empty () part = re.sub(r'\s*\{\s*\}', '', part) # empty {} @@ -8405,8 +8418,9 @@ def _get_file_path_from_template(context: dict, template_type: str = 'album_path if part: cleaned_folders.append(part) - # Substitute $quality in filename only + # Substitute $quality and $disc in filename only filename_base = filename_base.replace('$quality', quality_value) + filename_base = filename_base.replace('$disc', disc_value) # Clean up empty brackets/parens from any variable that resolved to empty filename_base = re.sub(r'\s*\[\s*\]', '', filename_base) filename_base = re.sub(r'\s*\(\s*\)', '', filename_base) @@ -8423,8 +8437,9 @@ def _get_file_path_from_template(context: dict, template_type: str = 'album_path return folder_path, filename else: - # Single component, treat as filename — substitute $quality + # Single component, treat as filename — substitute $quality and $disc full_path = full_path.replace('$quality', quality_value) + full_path = full_path.replace('$disc', disc_value) full_path = re.sub(r'\s*\[\s*\]', '', full_path) full_path = re.sub(r'\s*\(\s*\)', '', full_path) full_path = re.sub(r'\s*\{\s*\}', '', full_path) diff --git a/webui/index.html b/webui/index.html index 3c04de6..b1dcf51 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3234,22 +3234,31 @@ - Variables: $albumartist, $artist, $album, $title, - $track, $year, $quality (filename only) + Variables: $albumartist, $artist, $artistletter, $album, $title, + $track, $disc, $year, $quality (filename only)
- Variables: $artist, $title, $album, $year, $quality (filename only) + Variables: $artist, $artistletter, $title, $album, $year, $quality (filename only)
- Variables: $playlist, $artist, $title, $year, $quality (filename only) + Variables: $playlist, $artist, $artistletter, $title, $year, $quality (filename only) +
+ +
+ + + Label used for auto-created disc subfolders on multi-disc albums.
diff --git a/webui/static/script.js b/webui/static/script.js index 33a1da0..0f5dba4 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1625,9 +1625,9 @@ function validateFileOrganizationTemplates() { // Valid variables for each template type const validVars = { - album: ['$artist', '$albumartist', '$album', '$title', '$track', '$year', '$quality'], - single: ['$artist', '$albumartist', '$album', '$title', '$year', '$quality'], - playlist: ['$artist', '$playlist', '$title', '$year', '$quality'] + album: ['$artist', '$albumartist', '$artistletter', '$album', '$title', '$track', '$disc', '$year', '$quality'], + single: ['$artist', '$albumartist', '$artistletter', '$album', '$title', '$year', '$quality'], + playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'] }; // Get template values @@ -1814,6 +1814,7 @@ async function loadSettingsData() { document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title'; document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title'; document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title'; + document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc'; // Populate Playlist Sync settings document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false; @@ -2268,6 +2269,7 @@ async function saveSettings(quiet = false) { }, file_organization: { enabled: document.getElementById('file-organization-enabled').checked, + disc_label: document.getElementById('disc-label').value, templates: { album_path: document.getElementById('template-album-path').value, single_path: document.getElementById('template-single-path').value,