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.
main
Broque Thomas 1 day ago
parent d2adf17ca5
commit cfa4a0c59f

@ -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",

@ -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",

@ -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)

@ -3234,22 +3234,31 @@
<label>Album Path Template:</label>
<input type="text" id="template-album-path"
placeholder="$albumartist/$albumartist - $album/$track - $title">
<small style="color: #888;">Variables: $albumartist, $artist, $album, $title,
$track, $year, $quality (filename only)</small>
<small style="color: #888;">Variables: $albumartist, $artist, $artistletter, $album, $title,
$track, $disc, $year, $quality (filename only)</small>
</div>
<div class="form-group">
<label>Single Path Template:</label>
<input type="text" id="template-single-path"
placeholder="$artist/$artist - $title/$title">
<small style="color: #888;">Variables: $artist, $title, $album, $year, $quality (filename only)</small>
<small style="color: #888;">Variables: $artist, $artistletter, $title, $album, $year, $quality (filename only)</small>
</div>
<div class="form-group">
<label>Playlist Path Template:</label>
<input type="text" id="template-playlist-path"
placeholder="$playlist/$artist - $title">
<small style="color: #888;">Variables: $playlist, $artist, $title, $year, $quality (filename only)</small>
<small style="color: #888;">Variables: $playlist, $artist, $artistletter, $title, $year, $quality (filename only)</small>
</div>
<div class="form-group">
<label>Multi-Disc Folder Label:</label>
<select id="disc-label">
<option value="Disc">Disc (e.g., Disc 1/)</option>
<option value="CD">CD (e.g., CD 1/)</option>
</select>
<small style="color: #888;">Label used for auto-created disc subfolders on multi-disc albums.</small>
</div>
<div class="form-group">

@ -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,

Loading…
Cancel
Save