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).
129 lines
5 KiB
Python
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"]
|