soulsync/core/imports/filename.py
Broque Thomas 85426a210c Fix album-bundle downloads landing every track as track 1
Soulseek album-bundle (and any other release-staging path) was
importing every file with ``track_number=1`` because the staging
metadata reader used the auto-import-flavor filename extractor:
``extract_track_number_from_filename`` returns 1 when the basename
has no ``NN -`` prefix. That's the right default for the loose
auto-import flow (single file in, no upstream metadata to lean
on), but completely wrong for staging-cache reads:

- For an album-bundle download the user has authoritative track
  numbers in the Spotify track list flowing through to
  ``track_info`` for each task.
- ``try_staging_match`` in ``core/downloads/staging.py`` was
  meant to use those numbers when the staged file's own metadata
  doesn't have them.
- But the staging cache populated ``track_number=1`` for every
  untagged bare-title file (e.g. ``Cha-La Head-Cha-La.flac``), the
  album-bundle resolution branch reads file-side first, sees 1,
  and short-circuits the rest of the chain.

Fix:

- New ``extract_explicit_track_number`` in
  ``core/imports/filename.py`` — strict variant that returns
  ``0`` when no numeric prefix is visible. Docstring explicitly
  contrasts with the legacy 1-defaulting helper so future
  callers pick the right one.
- ``read_staging_file_metadata`` in ``core/imports/staging.py``
  now uses the strict extractor, so the staging file dict
  carries ``track_number=0`` ("unknown") instead of ``1`` for
  untagged bare-title files.
- The legacy ``extract_track_number_from_filename`` keeps its
  1-default behavior so auto-import callers + the post-process
  template fallbacks are unchanged; it's now implemented in
  terms of the strict variant.
- Tag-side parsing also tightened to require ``> 0`` before
  overriding the filename-derived value.

3 new tests pin the contracts:
- ``test_extract_explicit_track_number_returns_zero_when_no_prefix``
- ``test_read_staging_file_metadata_returns_zero_track_when_unknown``
- existing ``test_extract_track_number_from_filename_handles_common_patterns``
  now explicitly comments why bare filenames keep returning 1.

758 tests across imports + downloads + repair + staging-provenance
suites green. WHATS_NEW entry added under 2.6.3.

Reported against an album-bundle download of Ryoto's
"Cha-La Head-Cha-La" where slskd staged 15 untagged FLAC files
named after the song titles only.
2026-05-26 21:04:27 -07:00

116 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Filename parsing helpers used by import flows."""
from __future__ import annotations
import os
import re
from typing import Any, Dict
_TRACK_PATTERNS = (
r"^(\d+)\s*[-\.]\s*(.+?)\s*[-]\s*(.+)$",
r"^(.+?)\s*[-]\s*(.+)$",
r"^(\d+)\s*[-\.]\s*(.+)$",
)
def extract_track_number_from_filename(filename: str, title: str = None) -> int:
"""Extract track number from a filename. Returns 1 if not found.
Use ``extract_explicit_track_number`` instead when the caller needs
to distinguish "track 1" from "unknown" — staging-file readers in
particular MUST NOT conflate a bare title (no numeric prefix) with
track 1, or every untagged album-bundle file gets imported as
``track_number=1`` and downstream callers can't recover the real
number from authoritative metadata (Spotify track list, etc.).
"""
num = extract_explicit_track_number(filename)
return num if num > 0 else 1
def extract_explicit_track_number(filename: str) -> int:
"""Extract a track number only when the filename visibly carries one.
Returns the parsed track number when the basename starts with a
recognizable numeric prefix (``"01 - Title"``, ``"1-03 Title"``,
``"(01) Title"``, ``"[01] Title"``); returns ``0`` when no such
prefix is present. This is the contract staging readers want —
"unknown" must stay unknown so a downstream consumer with better
info (Spotify metadata, MusicBrainz, etc.) can fill it in.
"""
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))
if 1 <= num <= 99:
return num
match = re.match(r"^\(?(\d{1,3})\)?\s*[\-\.)\]]\s*", basename)
if match:
num = int(match.group(1))
if 1 <= num <= 999:
return num
return 0
def parse_filename_metadata(filename: str) -> Dict[str, Any]:
"""Extract artist/title/album hints from a loose filename."""
raw_path = str(filename or "")
normalized_path = raw_path.replace("\\", "/")
base_name = os.path.splitext(os.path.basename(normalized_path))[0]
result: Dict[str, Any] = {
"artist": "",
"title": "",
"album": "",
"track_number": None,
}
if not base_name:
return result
for pattern in _TRACK_PATTERNS:
match = re.match(pattern, base_name)
if not match:
continue
groups = match.groups()
if len(groups) == 3:
try:
result["track_number"] = int(groups[0])
result["artist"] = result["artist"] or groups[1].strip()
result["title"] = result["title"] or groups[2].strip()
except ValueError:
result["artist"] = result["artist"] or groups[0].strip()
result["title"] = result["title"] or f"{groups[1]} - {groups[2]}".strip()
elif len(groups) == 2:
if groups[0].isdigit():
try:
result["track_number"] = int(groups[0])
result["title"] = result["title"] or groups[1].strip()
except ValueError:
pass
else:
result["artist"] = result["artist"] or groups[0].strip()
result["title"] = result["title"] or groups[1].strip()
break
if not result["title"]:
result["title"] = base_name
if not result["album"] and "/" in normalized_path:
path_parts = normalized_path.split("/")
for part in reversed(path_parts[:-1]):
if not part or part.startswith("@"):
continue
cleaned = re.sub(r"^\d+\s*[-\.]\s*", "", part).strip()
if len(cleaned) > 3:
result["album"] = cleaned
break
return result