soulsync/core/library/existing_album_folder.py
BoulderBadgeDad 1d16ac7978 Downloads: reuse an album's existing folder so batches don't split it (#829)
Tacobell444: when tracks land in an album across multiple batches (a wishlist
run, the Album Completeness job, a missed track re-downloaded later), the folder
is rebuilt from API metadata each time — so when $albumtype or $year come back
blank/different on a later batch, the folder NAME changes and the album splits,
forcing a Reorganize.

Fix: build_final_path_for_track now checks whether the album already lives in a
single folder on disk and, if so, drops the new track there instead of a freshly
templated folder. Match (chosen): exact stored Spotify album id first, then a
STRICT >=0.85 name+artist match (vs the 0.7 used elsewhere) — a wrong match here
misplaces a file. New core/library/existing_album_folder.resolve_existing_album_folder
holds the logic; always-on with template fallback.

Safety rails: only returns a folder UNDER the transfer dir (never a read-only
library/NAS mount), only when the album lives in EXACTLY ONE folder (multiple =
disc subfolders, which DatabaseTrack can't disambiguate — those defer to the
template), and any failure falls through to the template path. Added
MusicDatabase.get_album_by_spotify_album_id for the id-first lookup.

Tests: single-folder reuse, no-match, below-threshold, multi-folder defer,
outside-transfer reject, id-first, missing transfer dir, no-files-on-disk.
8 tests; 1556 path/import/download tests pass (only the known soundcloud
failures remain).
2026-06-09 13:47:25 -07:00

129 lines
5 KiB
Python

"""Reuse an album's existing on-disk folder for new downloads (#829).
When tracks are added to an album across multiple batches (a wishlist run, the
Album Completeness job, a missed track re-downloaded later), the destination
folder is normally rebuilt from API metadata each time. If ``$albumtype`` or
``$year`` come back blank/different on a later batch, the folder *name* changes
and the album splits across folders — forcing a Reorganize afterwards.
This resolves the folder the album *already* lives in so the new track joins its
existing files instead. Matching is deliberately conservative: the exact stored
Spotify album id first (definitive), then a STRICT (>= 0.85) name+artist match —
higher than the 0.7 used elsewhere, because a wrong match here misplaces a file.
Safety rails:
* Only ever returns a folder UNDER the transfer dir (the managed download
tree) — never a read-only library/NAS mount the resolver happens to find.
* Only reuses when the album lives in EXACTLY ONE folder on disk. Multiple
folders means disc subfolders (DatabaseTrack carries no disc number, so we
can't safely pick the right one) — those defer to the template path.
* Any failure returns None — the caller falls back to the normal template.
"""
from __future__ import annotations
import os
from typing import Any, Optional
from core.library.path_resolver import resolve_library_file_path
from utils.logging_config import get_logger
logger = get_logger("library.existing_album_folder")
# Strict — a wrong album match drops the file in the wrong folder.
_STRICT_ALBUM_CONFIDENCE = 0.85
def _is_under(child: str, parent: str) -> bool:
"""True if ``child`` is the same as or inside ``parent`` (normalized)."""
try:
child_n = os.path.normcase(os.path.normpath(os.path.abspath(child)))
parent_n = os.path.normcase(os.path.normpath(os.path.abspath(parent)))
return child_n == parent_n or child_n.startswith(parent_n + os.sep)
except Exception:
return False
def _find_album(db: Any, spotify_album_id: Optional[str], album_name: Optional[str],
album_artist: Optional[str], active_server: Optional[str],
expected_track_count: Optional[int]):
"""Stored Spotify id first, then a strict name+artist match. None on no match."""
if spotify_album_id:
try:
album = db.get_album_by_spotify_album_id(spotify_album_id)
if album:
return album
except Exception as e:
logger.debug("album-by-spotify-id lookup failed: %s", e)
if album_name and album_artist:
try:
match, confidence = db.check_album_exists_with_editions(
title=album_name, artist=album_artist,
confidence_threshold=_STRICT_ALBUM_CONFIDENCE,
expected_track_count=expected_track_count,
server_source=active_server,
)
if match and confidence >= _STRICT_ALBUM_CONFIDENCE:
return match
except Exception as e:
logger.debug("strict album name+artist match failed: %s", e)
return None
def resolve_existing_album_folder(
*,
db: Any,
transfer_dir: Optional[str],
album_name: Optional[str] = None,
album_artist: Optional[str] = None,
spotify_album_id: Optional[str] = None,
active_server: Optional[str] = None,
expected_track_count: Optional[int] = None,
config_manager: Any = None,
resolver=resolve_library_file_path,
) -> Optional[str]:
"""Return the on-disk folder an existing album lives in (so a new track joins
it) or None to fall back to the templated path. See module docstring."""
if not transfer_dir or not os.path.isdir(transfer_dir):
return None
if not db:
return None
album = _find_album(db, spotify_album_id, album_name, album_artist,
active_server, expected_track_count)
if not album:
return None
try:
tracks = db.get_tracks_by_album(album.id)
except Exception as e:
logger.debug("get_tracks_by_album(%s) failed: %s", getattr(album, 'id', '?'), e)
return None
folders = set()
for t in tracks:
file_path = getattr(t, 'file_path', None)
if not file_path:
continue
try:
resolved = resolver(file_path, transfer_folder=transfer_dir,
config_manager=config_manager)
except Exception:
resolved = None
if not resolved:
continue
folder = os.path.dirname(resolved)
if _is_under(folder, transfer_dir):
folders.add(os.path.normpath(folder))
# Single folder under the transfer dir → reuse it. Zero (nothing on disk yet)
# or many (disc subfolders) → let the template decide.
if len(folders) == 1:
reuse = next(iter(folders))
logger.info("[Existing Album Folder] Reusing '%s' for album '%s'",
reuse, getattr(album, 'title', album_name))
return reuse
return None
__all__ = ["resolve_existing_album_folder"]