diff --git a/core/downloads/staging.py b/core/downloads/staging.py index f7ca8906..d1d4e3c3 100644 --- a/core/downloads/staging.py +++ b/core/downloads/staging.py @@ -38,8 +38,6 @@ import re from dataclasses import dataclass from typing import Any, Callable -from core.imports.filename import extract_track_number_from_filename - # `shutil` and `SequenceMatcher` are imported inline inside try_staging_match() # to keep the lift byte-identical with the original web_server.py function body. @@ -61,6 +59,25 @@ def _coerce_positive_int(value: Any, default: int = 0) -> int: return coerced if coerced > 0 else default +def _extract_explicit_track_number(filename: str) -> int: + """Extract a track number only when the filename visibly carries one.""" + basename = os.path.splitext(os.path.basename(str(filename or '')))[0].strip() + if not basename: + return 0 + + match = re.match(r"^\d[\-\.](\d{1,2})\s*[\-\.]\s*", basename) + if match: + num = int(match.group(1)) + return num if 1 <= num <= 99 else 0 + + match = re.match(r"^\(?(\d{1,3})\)?\s*[\-\.)\]]\s*", basename) + if match: + num = int(match.group(1)) + return num if 1 <= num <= 999 else 0 + + return 0 + + def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]: """Return conservative title variants for release-file matching. @@ -320,12 +337,24 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps): file_track_number = ( _coerce_positive_int(best_match.get('track_number'), 0) or - extract_track_number_from_filename(best_match.get('full_path', '')) + _extract_explicit_track_number(best_match.get('full_path', '')) ) - file_disc_number = _coerce_positive_int(best_match.get('disc_number'), 1) + file_disc_number = _coerce_positive_int(best_match.get('disc_number'), 0) if _private_album_bundle_staging: - track_number = file_track_number - disc_number = file_disc_number + track_number = ( + file_track_number or + _coerce_positive_int(track_info.get('track_number'), 0) or + _coerce_positive_int(track_info.get('trackNumber'), 0) or + _coerce_positive_int(getattr(track, 'track_number', 0), 0) or + 1 + ) + disc_number = ( + file_disc_number or + _coerce_positive_int(track_info.get('disc_number'), 0) or + _coerce_positive_int(track_info.get('discNumber'), 0) or + _coerce_positive_int(getattr(track, 'disc_number', 0), 0) or + 1 + ) else: track_number = ( _coerce_positive_int(track_info.get('track_number'), 0) or @@ -337,7 +366,8 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps): _coerce_positive_int(track_info.get('disc_number'), 0) or _coerce_positive_int(track_info.get('discNumber'), 0) or _coerce_positive_int(getattr(track, 'disc_number', 0), 0) or - file_disc_number + file_disc_number or + 1 ) track_info['track_number'] = track_number track_info['disc_number'] = disc_number diff --git a/tests/downloads/test_downloads_staging.py b/tests/downloads/test_downloads_staging.py index 70e86b6b..097c3323 100644 --- a/tests/downloads/test_downloads_staging.py +++ b/tests/downloads/test_downloads_staging.py @@ -357,6 +357,49 @@ def test_private_album_bundle_staging_overrides_default_track_info_number(tmp_pa assert ctx['original_search_result']['filename'] == str(src_file) +def test_private_album_bundle_staging_keeps_task_number_when_file_has_no_number(tmp_path): + """Private release staging must not turn every unnumbered release file into track 1.""" + src_file = tmp_path / 'staging' / 'Katy Perry - Firework.flac' + src_file.parent.mkdir() + src_file.touch() + + def get_batch_field(_batch_id, field): + if field == 'album_bundle_source': + return 'soulseek' + if field == 'album_bundle_private_staging': + return True + return None + + deps = _build_deps( + transfer_path=str(tmp_path / 'transfer'), + staging_files=[ + { + 'full_path': str(src_file), + 'title': 'Firework', + 'artist': 'Katy Perry', + }, + ], + get_batch_field=get_batch_field, + ) + _seed_task('t6d', track_info={ + '_is_explicit_album_download': True, + '_explicit_album_context': {'id': 'alb', 'name': 'Teenage Dream: The Complete Confection'}, + '_explicit_artist_context': {'id': 'art', 'name': 'Katy Perry'}, + 'track_number': 4, + 'disc_number': 1, + }) + + ds.try_staging_match( + 't6d', 'b1', + _Track(name='Firework', artists=['Katy Perry']), + deps, + ) + + ctx = matched_downloads_context['staging_t6d'] + assert ctx['track_info']['track_number'] == 4 + assert ctx['original_search_result']['track_number'] == 4 + + def test_staging_title_match_accepts_feature_suffix_from_release_file(tmp_path): """Album releases can include featured artists in filenames.""" src_file = tmp_path / 'staging' / '05-kendrick_lamar-money_trees_(feat._jay_rock).flac' diff --git a/web_server.py b/web_server.py index 993cda4a..de2344c9 100644 --- a/web_server.py +++ b/web_server.py @@ -1068,6 +1068,13 @@ def _get_batch_max_concurrent(is_album=False, source=None): mode = config_manager.get('download_source.mode', 'soulseek') if mode == 'soulseek': return 1 + if mode == 'hybrid': + hybrid_order = config_manager.get('download_source.hybrid_order', []) or [] + if isinstance(hybrid_order, str): + hybrid_order = [hybrid_order] + first_source = next((str(s).strip().lower() for s in hybrid_order if str(s).strip()), '') + if first_source == 'soulseek': + return 1 return _get_max_concurrent() # --- Session Download Statistics --- @@ -16531,6 +16538,8 @@ def _get_staging_file_cache(batch_id): 'title': meta['title'] or '', 'artist': meta['albumartist'] or meta['artist'] or '', 'album': meta['album'] or '', + 'track_number': meta.get('track_number'), + 'disc_number': meta.get('disc_number'), 'extension': ext, })