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).
This commit is contained in:
parent
26368a80ab
commit
1d16ac7978
4 changed files with 320 additions and 2 deletions
|
|
@ -587,6 +587,45 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext, cr
|
|||
disc_label = _get_config_manager().get("file_organization.disc_label", "Disc")
|
||||
|
||||
folder_path, filename_base = get_file_path_from_template(template_context, "album_path")
|
||||
|
||||
# #829: if this album already lives in a single folder on disk, drop the
|
||||
# new track there instead of a freshly-templated folder — this is what
|
||||
# keeps an album from splitting when $albumtype/$year drift between
|
||||
# batches (wishlist, Album Completeness, a missed track later). Strict
|
||||
# match + transfer-dir-only + single-folder-only inside the resolver;
|
||||
# any miss falls through to the template path below. Best-effort.
|
||||
reuse_folder = None
|
||||
if filename_base:
|
||||
try:
|
||||
from core.library.existing_album_folder import resolve_existing_album_folder
|
||||
from database.music_database import get_database
|
||||
try:
|
||||
_active_server = _get_config_manager().get_active_media_server()
|
||||
except Exception:
|
||||
_active_server = None
|
||||
_spotify_album_id = (album_context.get("id")
|
||||
if album_context and str(source).startswith("spotify") else None)
|
||||
_expected_tracks = None
|
||||
if album_context and album_context.get("total_tracks"):
|
||||
_expected_tracks = _coerce_int(album_context.get("total_tracks"), 0) or None
|
||||
reuse_folder = resolve_existing_album_folder(
|
||||
db=get_database(),
|
||||
transfer_dir=transfer_dir,
|
||||
album_name=album_info.get("album_name"),
|
||||
album_artist=template_context.get("albumartist"),
|
||||
spotify_album_id=_spotify_album_id,
|
||||
active_server=_active_server,
|
||||
expected_track_count=_expected_tracks,
|
||||
config_manager=_get_config_manager(),
|
||||
)
|
||||
except Exception as _reuse_err:
|
||||
logger.debug("[Existing Album Folder] lookup failed: %s", _reuse_err)
|
||||
reuse_folder = None
|
||||
if reuse_folder and filename_base:
|
||||
final_path = os.path.join(reuse_folder, filename_base + file_ext)
|
||||
_ensure_dir(reuse_folder, exist_ok=True)
|
||||
return final_path, True
|
||||
|
||||
if folder_path and filename_base:
|
||||
if total_discs > 1 and not user_controls_disc:
|
||||
disc_folder = f"{disc_label} {disc_number}"
|
||||
|
|
|
|||
129
core/library/existing_album_folder.py
Normal file
129
core/library/existing_album_folder.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""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"]
|
||||
|
|
@ -6260,11 +6260,47 @@ class MusicDatabase:
|
|||
))
|
||||
|
||||
return tracks
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tracks for album {album_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_album_by_spotify_album_id(self, spotify_album_id: str) -> Optional[DatabaseAlbum]:
|
||||
"""Fetch a single album by its (enriched) Spotify album id, or None.
|
||||
|
||||
Used by the download path builder (#829) to reuse an album's existing
|
||||
on-disk folder when re-downloading into the same album — matching the
|
||||
exact stored Spotify id before falling back to fuzzy name+artist.
|
||||
"""
|
||||
if not spotify_album_id:
|
||||
return None
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT albums.*, artists.name as artist_name
|
||||
FROM albums
|
||||
JOIN artists ON albums.artist_id = artists.id
|
||||
WHERE albums.spotify_album_id = ?
|
||||
LIMIT 1
|
||||
""", (spotify_album_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
genres = json.loads(row['genres']) if row['genres'] else None
|
||||
album = DatabaseAlbum(
|
||||
id=row['id'], artist_id=row['artist_id'], title=row['title'],
|
||||
year=row['year'], thumb_url=row['thumb_url'], genres=genres,
|
||||
track_count=row['track_count'], duration=row['duration'],
|
||||
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
|
||||
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
|
||||
)
|
||||
album.artist_name = row['artist_name']
|
||||
return album
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting album by spotify_album_id {spotify_album_id}: {e}")
|
||||
return None
|
||||
|
||||
def search_artists(self, query: str, limit: int = 50, server_source: str = None) -> List[DatabaseArtist]:
|
||||
"""Search artists by name, optionally filtered by server source.
|
||||
Uses diacritic-insensitive matching so 'Tiesto' finds 'Tiësto'."""
|
||||
|
|
|
|||
114
tests/test_existing_album_folder.py
Normal file
114
tests/test_existing_album_folder.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Reuse an album's existing on-disk folder for new downloads (#829).
|
||||
|
||||
Tacobell444: tracks added to an album across batches split into different folders
|
||||
when $albumtype/$year drift. The resolver finds the album's existing single
|
||||
folder (under the transfer dir) so the new track joins it. These pin the safety
|
||||
rails: strict match, transfer-dir-only, single-folder-only, id-first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.library.existing_album_folder import resolve_existing_album_folder
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, album=None, album_conf=0.0, tracks=None, by_spotify=None):
|
||||
self._album = album
|
||||
self._album_conf = album_conf
|
||||
self._tracks = tracks or []
|
||||
self._by_spotify = by_spotify
|
||||
|
||||
def get_album_by_spotify_album_id(self, sid):
|
||||
return self._by_spotify
|
||||
|
||||
def check_album_exists_with_editions(self, title, artist, confidence_threshold=0.8,
|
||||
expected_track_count=None, server_source=None, **kw):
|
||||
return (self._album, self._album_conf)
|
||||
|
||||
def get_tracks_by_album(self, album_id):
|
||||
return self._tracks
|
||||
|
||||
|
||||
def _track(path):
|
||||
return SimpleNamespace(file_path=path)
|
||||
|
||||
|
||||
def _album(id=1, title="Ocean Avenue"):
|
||||
return SimpleNamespace(id=id, title=title)
|
||||
|
||||
|
||||
def _mkfile(folder, name):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
f = folder / name
|
||||
f.write_text("x")
|
||||
return str(f)
|
||||
|
||||
|
||||
def test_reuses_single_folder_under_transfer(tmp_path):
|
||||
album_dir = tmp_path / "Yellowcard - Ocean Avenue"
|
||||
f1 = _mkfile(album_dir, "01 - Way Away.mp3")
|
||||
f2 = _mkfile(album_dir, "02 - Breathing.mp3")
|
||||
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[_track(f1), _track(f2)])
|
||||
out = resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path),
|
||||
album_name="Ocean Avenue", album_artist="Yellowcard")
|
||||
assert out == os.path.normpath(str(album_dir))
|
||||
|
||||
|
||||
def test_no_match_returns_none(tmp_path):
|
||||
db = _FakeDb(album=None, album_conf=0.0)
|
||||
assert resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path), album_name="X", album_artist="Y") is None
|
||||
|
||||
|
||||
def test_below_strict_threshold_returns_none(tmp_path):
|
||||
f = _mkfile(tmp_path / "A", "1.mp3")
|
||||
# 0.80 < the resolver's 0.85 strict gate -> not reused.
|
||||
db = _FakeDb(album=_album(), album_conf=0.80, tracks=[_track(f)])
|
||||
assert resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path), album_name="A", album_artist="B") is None
|
||||
|
||||
|
||||
def test_multi_folder_defers_to_template(tmp_path):
|
||||
f1 = _mkfile(tmp_path / "Album" / "Disc 01", "1.mp3")
|
||||
f2 = _mkfile(tmp_path / "Album" / "Disc 02", "1.mp3")
|
||||
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[_track(f1), _track(f2)])
|
||||
assert resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path), album_name="A", album_artist="B") is None
|
||||
|
||||
|
||||
def test_folder_outside_transfer_returns_none(tmp_path):
|
||||
f = _mkfile(tmp_path / "outside", "1.mp3")
|
||||
transfer = tmp_path / "transfer"
|
||||
transfer.mkdir()
|
||||
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[_track(f)])
|
||||
assert resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(transfer), album_name="A", album_artist="B") is None
|
||||
|
||||
|
||||
def test_id_first_match_skips_name_lookup(tmp_path):
|
||||
album_dir = tmp_path / "Album"
|
||||
f = _mkfile(album_dir, "1.mp3")
|
||||
# name match would FAIL (album=None); the stored spotify id hits.
|
||||
db = _FakeDb(album=None, album_conf=0.0, tracks=[_track(f)], by_spotify=_album())
|
||||
out = resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path), spotify_album_id="sp123",
|
||||
album_name="X", album_artist="Y")
|
||||
assert out == os.path.normpath(str(album_dir))
|
||||
|
||||
|
||||
def test_missing_transfer_dir_returns_none(tmp_path):
|
||||
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[])
|
||||
assert resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path / "nope"), album_name="A", album_artist="B") is None
|
||||
|
||||
|
||||
def test_album_with_no_files_on_disk_returns_none(tmp_path):
|
||||
# Album matched but its tracks have no resolvable file -> nothing to reuse.
|
||||
db = _FakeDb(album=_album(), album_conf=0.95,
|
||||
tracks=[_track("/gone/1.mp3"), _track(None)])
|
||||
assert resolve_existing_album_folder(
|
||||
db=db, transfer_dir=str(tmp_path), album_name="A", album_artist="B") is None
|
||||
Loading…
Reference in a new issue