From 78fa83c8acb1a2577dc7c535e5a41eebe55e1394 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:55:37 -0700 Subject: [PATCH] Add $cdnum template variable for multi-disc filenames New smart template variable that emits "CD01" / "CD02" etc. in filenames on multi-disc albums, and expands to empty string on single-disc albums so mixed libraries don't end up with "CD01" on every single. Template behaviour: - total_discs > 1 -> "CD{disc:02d}" (zero-padded, CD prefix) - total_discs <= 1 -> empty string - Both $cdnum and ${cdnum} bracket form supported - Empty value collapses cleanly via existing double-dash regex plus new leading-dash cleanup pass Wiring: - _apply_path_template in web_server.py (download pipeline) - _apply_path_template in core/repair_jobs/library_reorganize.py (Reorganize repair job) - total_discs added to every album-mode template context: * download pipeline album branch (uses resolved total_discs even for single-track downloads from search) * per-album Reorganize preview + apply endpoints (pre-scan all track tags once, take max disc_number) * Library Reorganize repair job (already had album_total_discs map, just added to context dict) Leading-dash cleanup added to _get_file_path_from_template (web_server) and _build_path_from_template (library_reorganize) so templates like "$cdnum - $track - $title" don't leave "- 05 - Title" on single-disc albums. UI: - Template hint in Settings -> File Organization documents $cdnum - Template validation variable list includes $cdnum - Reorganize modal variable reference shows $cdnum with example "CD01" Verified: - Multi-disc disc 1 -> "CD01 - 05 - Track" - Multi-disc disc 2 -> "CD02 - 05 - Track" - Single-disc -> "05 - Track" (no leading dash) - Templates without $cdnum behave unchanged - 276/276 tests pass --- core/repair_jobs/library_reorganize.py | 14 +++++ web_server.py | 75 ++++++++++++++++++++------ webui/index.html | 2 +- webui/static/script.js | 3 +- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index 80a068e0..3dc11245 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -75,7 +75,14 @@ def _sanitize_context_values(context: dict) -> dict: def _apply_path_template(template: str, context: dict) -> str: """Apply template variables to build a path string.""" clean = _sanitize_context_values(context) + # $cdnum — smart CD label. "CD01"/"CD02" only when multi-disc, empty for + # single-disc (collapses cleanly via the double-dash regex at the end). + _total_discs = int(clean.get('total_discs', 1) or 1) + _disc_number = int(clean.get('disc_number', 1) or 1) + cdnum_value = f"CD{_disc_number:02d}" if _total_discs > 1 else '' + result = template + result = result.replace('${cdnum}', cdnum_value) result = result.replace('$albumartist', clean.get('albumartist', clean.get('artist', 'Unknown Artist'))) result = result.replace('$albumtype', clean.get('albumtype', 'Album')) result = result.replace('$playlist', clean.get('playlist_name', '')) @@ -83,6 +90,7 @@ def _apply_path_template(template: str, context: dict) -> str: result = result.replace('$artist', clean.get('artist', 'Unknown Artist')) result = result.replace('$album', clean.get('album', 'Unknown Album')) result = result.replace('$title', clean.get('title', 'Unknown Track')) + result = result.replace('$cdnum', cdnum_value) result = result.replace('$track', f"{clean.get('track_number', 1):02d}") result = result.replace('$year', str(clean.get('year', ''))) result = re.sub(r'\s+', ' ', result) @@ -120,6 +128,9 @@ def _build_path_from_template(template: str, context: dict) -> tuple: filename_base = re.sub(r'\s*\(\s*\)', '', filename_base) filename_base = re.sub(r'\s*\{\s*\}', '', filename_base) filename_base = re.sub(r'\s*-\s*$', '', filename_base) + # Leading dash cleanup — lets $cdnum at the start of a filename + # cleanly disappear on single-disc albums (empty-value case). + filename_base = re.sub(r'^\s*-\s*', '', filename_base) filename_base = re.sub(r'\s+', ' ', filename_base).strip() sanitized_folders = [_sanitize_filename(p) for p in cleaned_folders] @@ -419,6 +430,9 @@ class LibraryReorganizeJob(RepairJob): 'title': title, 'track_number': track_number, 'disc_number': disc_number, + # total_discs lets $cdnum decide whether to emit "CDxx" or + # stay empty (single-disc albums). + 'total_discs': total_discs, 'year': year, 'quality': quality, 'albumtype': 'Album', diff --git a/web_server.py b/web_server.py index 4cc396cc..0f68bf20 100644 --- a/web_server.py +++ b/web_server.py @@ -13375,20 +13375,29 @@ def reorganize_album_preview(album_id): transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) + # Pre-scan disc numbers so every track's template context carries the + # same total_discs. Needed by $cdnum (smart CD label) so the template + # can decide whether to emit "CDxx" or stay empty for single-disc. + track_disc_numbers = {} + for _t in tracks: + _rp = _resolve_library_file_path(_t.get('file_path')) if _t.get('file_path') else None + _dn = 1 + if _rp: + try: + from core.tag_writer import read_file_tags + _dn = read_file_tags(_rp).get('disc_number') or 1 + except Exception: + _dn = 1 + track_disc_numbers[_t.get('id')] = int(_dn) + total_discs = max(track_disc_numbers.values(), default=1) if track_disc_numbers else 1 + preview_items = [] for track in tracks: file_path = track.get('file_path') resolved = _resolve_library_file_path(file_path) if file_path else None - # Read disc_number from file tags if available - disc_number = 1 - if resolved: - try: - from core.tag_writer import read_file_tags - file_tags = read_file_tags(resolved) - disc_number = file_tags.get('disc_number') or 1 - except Exception: - pass + # Reuse the disc number captured in the pre-scan (avoids re-reading tags) + disc_number = track_disc_numbers.get(track.get('id'), 1) # Get file extension from current path file_ext = os.path.splitext(resolved or file_path or '.mp3')[1] @@ -13405,6 +13414,7 @@ def reorganize_album_preview(album_id): 'title': track.get('title') or 'Unknown Track', 'track_number': track.get('track_number') or 1, 'disc_number': disc_number, + 'total_discs': total_discs, 'year': year_val, 'quality': quality, 'albumtype': _get_album_type_display( @@ -13536,6 +13546,22 @@ def reorganize_album_files(album_id): bg_db = get_database() bg_conn = bg_db._get_connection() + # Pre-scan disc numbers for every track so total_discs is the + # same for all template contexts in this album. Needed by the + # $cdnum template variable to decide multi-disc vs single-disc. + track_disc_numbers = {} + for _t in tracks: + _rp = _resolve_library_file_path(_t.get('file_path')) if _t.get('file_path') else None + _dn = 1 + if _rp: + try: + from core.tag_writer import read_file_tags + _dn = read_file_tags(_rp).get('disc_number') or 1 + except Exception: + _dn = 1 + track_disc_numbers[_t.get('id')] = int(_dn) + total_discs = max(track_disc_numbers.values(), default=1) if track_disc_numbers else 1 + # Pre-compute all destination paths to detect collisions dest_paths = {} # normalized_new_path -> track_id for track in tracks: @@ -13544,13 +13570,8 @@ def reorganize_album_files(album_id): if not resolved: continue - disc_number = 1 - try: - from core.tag_writer import read_file_tags - file_tags = read_file_tags(resolved) - disc_number = file_tags.get('disc_number') or 1 - except Exception: - pass + # Reuse the disc number from the pre-scan pass above + disc_number = track_disc_numbers.get(track.get('id'), 1) file_ext = os.path.splitext(resolved)[1] quality = _get_audio_quality_string(resolved) @@ -13563,6 +13584,7 @@ def reorganize_album_files(album_id): 'title': track.get('title') or 'Unknown Track', 'track_number': track.get('track_number') or 1, 'disc_number': disc_number, + 'total_discs': total_discs, 'year': year_val, 'quality': quality, 'albumtype': _get_album_type_display( @@ -18109,6 +18131,10 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext): disc_label = config_manager.get('file_organization.disc_label', 'Disc') + # total_discs was resolved above — pass it into the template context so + # $cdnum can emit "CDxx" only for real multi-disc albums. + template_context['total_discs'] = total_discs + folder_path, filename_base = _get_file_path_from_template(template_context, 'album_path') if folder_path and filename_base: if total_discs > 1 and not user_controls_disc: @@ -18717,6 +18743,14 @@ def _apply_path_template(template: str, context: dict) -> str: album_artist_value = resolved except Exception: pass + # $cdnum — smart CD label for multi-disc filenames. Produces "CD01" / + # "CD02" etc. when the album has 2+ discs, empty string otherwise. + # Empty output collapses gracefully via the trailing double-dash cleanup + # regex, so single-disc albums don't end up with "CD01" in every name. + _total_discs = int(clean_context.get('total_discs', 1) or 1) + _disc_number = int(clean_context.get('disc_number', 1) or 1) + cdnum_value = f"CD{_disc_number:02d}" if _total_discs > 1 else '' + # Support ${var} delimited syntax (e.g. ${albumtype}s → Albums) # Must run before $var replacements to prevent partial matching _bracket_map = { @@ -18728,6 +18762,7 @@ def _apply_path_template(template: str, context: dict) -> str: 'album': clean_context.get('album', 'Unknown Album'), 'title': clean_context.get('title', 'Unknown Track'), 'track': f"{clean_context.get('track_number', 1):02d}", + 'cdnum': cdnum_value, 'disc': str(clean_context.get('disc_number', 1)), 'discnum': str(clean_context.get('disc_number', 1)), 'year': str(clean_context.get('year', '')), @@ -18745,6 +18780,10 @@ def _apply_path_template(template: str, context: dict) -> str: 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')) + # $cdnum must replace before $track to avoid conflict with variables that + # start with "$c" — no such variable exists today but this ordering + # mirrors the "longest first" rule used throughout this function. + result = result.replace('$cdnum', cdnum_value) result = result.replace('$track', f"{clean_context.get('track_number', 1):02d}") result = result.replace('$year', str(clean_context.get('year', ''))) # Empty string instead of 'Unknown' @@ -18830,6 +18869,9 @@ def _get_file_path_from_template(context: dict, template_type: str = 'album_path filename_base = re.sub(r'\s*\(\s*\)', '', filename_base) filename_base = re.sub(r'\s*\{\s*\}', '', filename_base) filename_base = re.sub(r'\s*-\s*$', '', filename_base) + # Leading dash cleanup — lets $cdnum (and other optional vars) sit at + # the start of the filename without leaving a stray "- " when empty. + filename_base = re.sub(r'^\s*-\s*', '', filename_base) filename_base = re.sub(r'\s+', ' ', filename_base).strip() # Sanitize each folder component @@ -18849,6 +18891,7 @@ def _get_file_path_from_template(context: dict, template_type: str = 'album_path 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) + full_path = re.sub(r'^\s*-\s*', '', full_path) full_path = re.sub(r'\s+', ' ', full_path).strip() return '', _sanitize_filename(full_path) diff --git a/webui/index.html b/webui/index.html index 025bf1fa..85eb9885 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5099,7 +5099,7 @@ Variables: $albumartist, $artist, $artistletter, $album, $albumtype, - $title, $track, $disc (01), $discnum (1), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums + $title, $track, $disc (01), $discnum (1), $cdnum (CD01 on multi-disc, empty on single), $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 3fded92f..e565e80d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5677,7 +5677,7 @@ function validateFileOrganizationTemplates() { // Valid variables for each template type const validVars = { - album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$year', '$quality'], + album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$cdnum', '$year', '$quality'], single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$year', '$quality'], playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'], video: ['$artist', '$artistletter', '$title', '$year'] @@ -50316,6 +50316,7 @@ async function showReorganizeModal(albumId) { { var: '$title', desc: 'Track title', example: 'Track Name' }, { var: '$track', desc: 'Track number (zero-padded)', example: '01' }, { var: '$disc', desc: 'Disc number (filename only)', example: '01' }, + { var: '$cdnum', desc: 'CD label — "CD01" on multi-disc, empty otherwise', example: 'CD01' }, { var: '$year', desc: 'Release year', example: albumData && albumData.year ? String(albumData.year) : '2024' }, { var: '$quality', desc: 'Audio quality (filename only)', example: 'FLAC 16bit/44kHz' }, ];