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.
This commit is contained in:
parent
d2adf17ca5
commit
cfa4a0c59f
5 changed files with 48 additions and 22 deletions
|
|
@ -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…
Reference in a new issue