/
/
/
1"""Tests for the bounded, validated NFO-based folder resolution fallback."""
2
3from __future__ import annotations
4
5import os
6from typing import Any
7from unittest.mock import AsyncMock, MagicMock, patch
8
9import pytest
10from music_assistant_models.enums import AlbumType, ExternalID
11from music_assistant_models.errors import InvalidDataError, MediaNotFoundError
12from music_assistant_models.media_items import Album
13
14from music_assistant.controllers.cache import BYPASS_CACHE
15from music_assistant.helpers.util import parse_title_and_version
16from music_assistant.providers.filesystem_local import _ONDEMAND_NFO_ITEMS, LocalFileSystemProvider
17from music_assistant.providers.filesystem_local.helpers import FileSystemItem
18
19INSTANCE_ID = "filesystem_local--test"
20ALBUM_MBID = "11111111-1111-1111-1111-111111111111"
21OTHER_MBID = "22222222-2222-2222-2222-222222222222"
22ARTIST_MBID = "33333333-3333-3333-3333-333333333333"
23
24
25def _provider() -> Any:
26 """Create a bare provider with a mocked cache, outside a sync (on-demand resolution)."""
27 with patch.object(LocalFileSystemProvider, "__init__", lambda *_a, **_kw: None):
28 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
29 provider.logger = MagicMock()
30 provider.mass = MagicMock()
31 provider.config = MagicMock(instance_id=INSTANCE_ID)
32 provider.media_content_type = "music"
33 provider.cache = MagicMock()
34 provider.sync_running = False
35 provider._sync_nfo_by_dir = {}
36 provider._sync_nfo_index_ready = False
37 provider._cue = MagicMock()
38 return provider
39
40
41def _item(relative_path: str) -> FileSystemItem:
42 """Build a minimal FileSystemItem for a resolved NFO file."""
43 return FileSystemItem(
44 filename=relative_path.rsplit("/", 1)[-1],
45 relative_path=relative_path,
46 absolute_path=f"/media/{relative_path}",
47 is_dir=False,
48 checksum="1",
49 )
50
51
52def _mock_single_file(provider: Any, path: str, data: bytes) -> None:
53 """Make exactly one NFO file exist, discoverable via a folder listing (on demand)."""
54 folder, _sep, _name = path.rpartition("/")
55 item = _item(path)
56
57 async def _scandir(scan_folder: str, use_cache: bool = True) -> list[FileSystemItem]: # noqa: ARG001
58 return [item] if scan_folder == folder else []
59
60 provider._scandir = AsyncMock(side_effect=_scandir)
61 provider._read_file = AsyncMock(return_value=data)
62
63
64def _async_iter(items: list[Any]) -> Any:
65 """Build an async-generator stand-in for a controller's `iter_library_items`."""
66
67 async def _iter(*_args: object, **_kwargs: object) -> Any:
68 for item in items:
69 yield item
70
71 return _iter
72
73
74def _tags(
75 album: str | None = "My Album",
76 album_id: str | None = None,
77 rg_id: str | None = None,
78 album_artist_ids: tuple[str, ...] = (),
79) -> Any:
80 """Build minimal audio tags for album resolution."""
81 return MagicMock(
82 album=album,
83 filename="track.flac",
84 musicbrainz_albumid=album_id,
85 musicbrainz_releasegroupid=rg_id,
86 musicbrainz_albumartistids=album_artist_ids,
87 )
88
89
90# --- album.nfo resolution ----------------------------------------------------------------
91
92
93async def test_album_resolves_via_parent_nfo_with_matching_mbid() -> None:
94 """A recognized disc subfolder's own NFO is never tried; only the parent's matching NFO is."""
95 provider = _provider()
96 track_dir = "Artist/Album/Disc 1"
97 _mock_single_file(
98 provider,
99 "Artist/Album/album.nfo",
100 f"<album><title>Other Title</title>"
101 f"<musicbrainzalbumid>{ALBUM_MBID}</musicbrainzalbumid></album>".encode(),
102 )
103 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags(album_id=ALBUM_MBID))
104 assert result is not None
105 album_dir, nfo_item, root = result
106 assert album_dir == "Artist/Album"
107 assert nfo_item.relative_path == "Artist/Album/album.nfo"
108 assert root["title"] == "Other Title"
109
110
111async def test_album_resolves_via_title_match_when_no_mbid() -> None:
112 """A candidate's album.nfo title, matched against the track's album tag, is sufficient."""
113 provider = _provider()
114 track_dir = "Artist/CAT-1234" # a catalogue-number folder that folder matching cannot resolve
115 _mock_single_file(
116 provider, "Artist/CAT-1234/album.nfo", b"<album><title>My Album</title></album>"
117 )
118 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags())
119 assert result is not None
120 assert result[0] == track_dir
121
122
123async def test_album_title_match_ignores_edition_suffix_on_both_sides() -> None:
124 """An NFO title's own edition suffix is stripped the same way the tag's album name was."""
125 provider = _provider()
126 track_dir = "Artist/CAT-1234"
127 _mock_single_file(
128 provider,
129 "Artist/CAT-1234/album.nfo",
130 b"<album><title>My Album (Deluxe Edition)</title></album>",
131 )
132 result = await provider._resolve_album_dir_via_nfo(
133 track_dir, _tags(album="My Album (Deluxe Edition)")
134 )
135 assert result is not None
136 assert result[0] == track_dir
137
138
139async def test_album_conflicting_mbid_is_rejected_even_with_matching_title() -> None:
140 """A conflicting MusicBrainz album id is rejected outright, never falling back to title."""
141 provider = _provider()
142 track_dir = "Artist/CAT-1234"
143 _mock_single_file(
144 provider,
145 "Artist/CAT-1234/album.nfo",
146 f"<album><title>My Album</title>"
147 f"<musicbrainzalbumid>{OTHER_MBID}</musicbrainzalbumid></album>".encode(),
148 )
149 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags(album_id=ALBUM_MBID))
150 assert result is None
151
152
153async def test_album_matching_id_still_rejected_on_conflicting_second_id() -> None:
154 """A matching album id does not short-circuit a conflicting release-group id."""
155 provider = _provider()
156 track_dir = "Artist/CAT-1234"
157 other_rg_mbid = "44444444-4444-4444-4444-444444444444"
158 _mock_single_file(
159 provider,
160 "Artist/CAT-1234/album.nfo",
161 f"<album><title>My Album</title>"
162 f"<musicbrainzalbumid>{ALBUM_MBID}</musicbrainzalbumid>"
163 f"<musicbrainzreleasegroupid>{other_rg_mbid}</musicbrainzreleasegroupid></album>".encode(),
164 )
165 result = await provider._resolve_album_dir_via_nfo(
166 track_dir, _tags(album_id=ALBUM_MBID, rg_id="55555555-5555-5555-5555-555555555555")
167 )
168 assert result is None
169
170
171async def test_album_falls_through_to_track_dir_when_parent_has_no_match() -> None:
172 """The track directory itself is tried when the parent has no (or no matching) album.nfo."""
173 provider = _provider()
174 track_dir = "Artist/CAT-1234"
175 _mock_single_file(
176 provider, "Artist/CAT-1234/album.nfo", b"<album><title>My Album</title></album>"
177 )
178 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags())
179 assert result is not None
180 assert result[0] == "Artist/CAT-1234"
181
182
183async def test_album_own_directory_nfo_takes_precedence_over_parent() -> None:
184 """
185 The track's own directory is tried before its parent, being the nearer, more specific one.
186
187 Otherwise a same-title album.nfo one level up (e.g. a stray leftover from a prior, flatter
188 single-album layout) could outrank the track's own, definitively correct album.nfo.
189 """
190 provider = _provider()
191 track_dir = "Artist/CAT-1234"
192
193 async def _scandir(scan_folder: str, use_cache: bool = True) -> list[FileSystemItem]: # noqa: ARG001
194 if scan_folder in ("Artist", "Artist/CAT-1234"):
195 return [_item(f"{scan_folder}/album.nfo")]
196 return []
197
198 async def _read_file(path: str) -> bytes:
199 return (
200 b"<album><title>My Album</title></album>"
201 if path == "Artist/CAT-1234/album.nfo"
202 else b"<album><title>My Album</title><year>1999</year></album>"
203 )
204
205 provider._scandir = AsyncMock(side_effect=_scandir)
206 provider._read_file = AsyncMock(side_effect=_read_file)
207
208 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags())
209 assert result is not None
210 assert result[0] == "Artist/CAT-1234"
211
212
213async def test_album_matches_nfo_regardless_of_filename_case() -> None:
214 """A case-insensitive filesystem's ALBUM.NFO resolves the same as a lowercase album.nfo."""
215 provider = _provider()
216 track_dir = "Artist/CAT-1234"
217 _mock_single_file(
218 provider, "Artist/CAT-1234/ALBUM.NFO", b"<album><title>My Album</title></album>"
219 )
220 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags())
221 assert result is not None
222 assert result[0] == track_dir
223
224
225async def test_album_malformed_nfo_stays_unresolved() -> None:
226 """Malformed (unparsable) NFO content leaves the album synthetic, not a failure."""
227 provider = _provider()
228 track_dir = "Artist/CAT-1234"
229 _mock_single_file(provider, "Artist/CAT-1234/album.nfo", b"not xml at all <<<")
230 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags())
231 assert result is None
232
233
234async def test_album_invalid_field_leaves_item_unresolved() -> None:
235 """A matching title with a later invalid field (bad year) does not resolve the folder."""
236 provider = _provider()
237 track_dir = "Artist/CAT-1234"
238 _mock_single_file(
239 provider,
240 "Artist/CAT-1234/album.nfo",
241 b"<album><title>My Album</title><year>not-a-year</year></album>",
242 )
243 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags())
244 assert result is None
245
246
247async def test_album_no_candidate_nfo_returns_none() -> None:
248 """No album.nfo at either candidate directory resolves to nothing, not an error."""
249 provider = _provider()
250 provider._scandir = AsyncMock(return_value=[])
251 result = await provider._resolve_album_dir_via_nfo("Artist/CAT-1234", _tags())
252 assert result is None
253
254
255async def test_album_resolution_never_uses_provider_root_as_identity() -> None:
256 """
257 A valid album.nfo living at the provider's own root is never trusted as identity.
258
259 Like the normal, non-NFO folder match, the provider root cannot identify one specific
260 album out of the many it may contain, even when a track happens to sit directly in it.
261 """
262 provider = _provider()
263 track_dir = "" # the track file lives directly in the provider's configured root
264 _mock_single_file(
265 provider,
266 "album.nfo",
267 f"<album><title>My Album</title>"
268 f"<musicbrainzalbumid>{ALBUM_MBID}</musicbrainzalbumid></album>".encode(),
269 )
270 result = await provider._resolve_album_dir_via_nfo(track_dir, _tags(album_id=ALBUM_MBID))
271 assert result is None
272
273
274async def test_album_transient_read_failure_propagates() -> None:
275 """A genuine read failure (not malformed content) propagates so the sync can defer/retry."""
276 provider = _provider()
277 track_dir = "Artist/CAT-1234"
278 _mock_single_file(provider, "Artist/CAT-1234/album.nfo", b"irrelevant")
279 provider._read_file = AsyncMock(side_effect=OSError("network hiccup"))
280 with pytest.raises(OSError, match="network hiccup"):
281 await provider._resolve_album_dir_via_nfo(track_dir, _tags())
282
283
284async def test_nfo_listing_bypasses_cache_during_a_forced_refresh() -> None:
285 """
286 A manual "Refresh item" must see an NFO just added, not a stale cloud directory listing.
287
288 `_scandir`'s `use_cache` is a cloud-backed provider's own separate, short-lived listing
289 cache (unrelated to the core cache controller's `self.cache`); it must be bypassed the same
290 way the core cache is, via the same `BYPASS_CACHE` context, or an NFO added to disk right
291 before a refresh could still be missed for up to that cache's own TTL.
292 """
293 provider = _provider()
294 provider._scandir = AsyncMock(return_value=[])
295
296 token = BYPASS_CACHE.set(True)
297 try:
298 await provider._list_nfo_candidates("Artist/Album")
299 finally:
300 BYPASS_CACHE.reset(token)
301
302 provider._scandir.assert_awaited_once_with("Artist/Album", use_cache=False)
303
304
305async def test_nfo_listing_uses_cache_outside_a_forced_refresh() -> None:
306 """Outside an explicit refresh, the cloud provider's own listing cache is left in play."""
307 provider = _provider()
308 provider._scandir = AsyncMock(return_value=[])
309
310 await provider._list_nfo_candidates("Artist/Album")
311
312 provider._scandir.assert_awaited_once_with("Artist/Album", use_cache=True)
313
314
315# --- artist.nfo resolution ---------------------------------------------------------------
316
317
318async def test_artist_resolves_via_ancestor_nfo_by_name() -> None:
319 """The nearest ancestor's artist.nfo, matched by name, resolves the artist's folder."""
320 provider = _provider()
321 album_dir = "Music/The Artist/Album"
322 _mock_single_file(
323 provider, "Music/The Artist/artist.nfo", b"<artist><title>The Artist</title></artist>"
324 )
325 result = await provider._resolve_artist_dir_via_nfo(album_dir, "The Artist", None)
326 assert result is not None
327 assert result[0] == "Music/The Artist"
328
329
330async def test_artist_resolves_via_ancestor_nfo_by_mbid() -> None:
331 """A matching MusicBrainz artist id resolves even when the name in the NFO differs."""
332 provider = _provider()
333 album_dir = "Music/Weird Folder Name/Album"
334 _mock_single_file(
335 provider,
336 "Music/Weird Folder Name/artist.nfo",
337 f"<artist><title>Some Other Name</title>"
338 f"<musicbrainzartistid>{ARTIST_MBID}</musicbrainzartistid></artist>".encode(),
339 )
340 result = await provider._resolve_artist_dir_via_nfo(album_dir, "The Artist", ARTIST_MBID)
341 assert result is not None
342 assert result[0] == "Music/Weird Folder Name"
343
344
345async def test_artist_marker_only_nfo_is_never_accepted() -> None:
346 """An artist.nfo with no id or name/title is never trusted as identity (no marker mode)."""
347 provider = _provider()
348 album_dir = "Music/Weird Folder Name/Album"
349 _mock_single_file(
350 provider, "Music/Weird Folder Name/artist.nfo", b"<artist><genre>Rock</genre></artist>"
351 )
352 result = await provider._resolve_artist_dir_via_nfo(album_dir, "The Artist", None)
353 assert result is None
354
355
356async def test_artist_resolution_bounded_to_three_ancestor_levels() -> None:
357 """A matching artist.nfo four levels up is never found (mirrors the normal lookup's bound)."""
358 provider = _provider()
359 album_dir = "A/B/C/D/Album"
360 _mock_single_file(provider, "A/artist.nfo", b"<artist><title>The Artist</title></artist>")
361 result = await provider._resolve_artist_dir_via_nfo(album_dir, "The Artist", None)
362 assert result is None
363
364
365async def test_artist_no_matching_ancestor_returns_none() -> None:
366 """No artist.nfo anywhere within the bound resolves to nothing, not an error."""
367 provider = _provider()
368 provider._scandir = AsyncMock(return_value=[])
369 result = await provider._resolve_artist_dir_via_nfo("Music/Artist/Album", "The Artist", None)
370 assert result is None
371
372
373async def test_artist_resolution_never_walks_into_provider_root() -> None:
374 """
375 A valid artist.nfo living at the provider's own root is never trusted as identity.
376
377 Like the normal, non-NFO folder match, the provider root cannot identify one specific
378 artist out of the many it may contain, so the ancestor walk stops one level short of it.
379 """
380 provider = _provider()
381 album_dir = "The Artist/Album" # "The Artist" is a top-level folder; its parent is the root
382 _mock_single_file(provider, "artist.nfo", b"<artist><title>The Artist</title></artist>")
383 result = await provider._resolve_artist_dir_via_nfo(album_dir, "The Artist", None)
384 assert result is None
385
386
387# --- payload validation -------------------------------------------------------------------
388
389
390def test_nfo_applies_cleanly_rejects_invalid_field() -> None:
391 """A field that would raise while applying to a scratch item fails validation."""
392 provider = _provider()
393 assert provider._nfo_applies_cleanly({"title": "Album", "year": "1999"}, "album") is True
394 assert provider._nfo_applies_cleanly({"title": "Album", "year": "not-a-year"}, "album") is False
395
396
397def test_nfo_applies_cleanly_rejects_non_scalar_field() -> None:
398 """A nested (dict-shaped) field, e.g. a malformed <genre> element, fails validation."""
399 provider = _provider()
400 non_scalar_genre = {"title": "Album", "genre": {"name": "Rock"}}
401 assert provider._nfo_applies_cleanly(non_scalar_genre, "album") is False
402
403
404def test_nfo_applies_cleanly_accepts_repeated_genre_list() -> None:
405 """A repeated <genre> element (xmltodict yields a list) is valid, split_items accepts it."""
406 provider = _provider()
407 repeated_genre = {"title": "Album", "genre": ["Rock", "Pop"]}
408 assert provider._nfo_applies_cleanly(repeated_genre, "album") is True
409
410
411def test_nfo_applies_cleanly_rejects_non_scalar_title() -> None:
412 """A non-scalar title (a plain assignment target, not a raising helper) also fails."""
413 provider = _provider()
414 non_scalar_title = {"title": {"name": "Album"}}
415 assert provider._nfo_applies_cleanly(non_scalar_title, "album") is False
416
417
418def test_nfo_applies_cleanly_rejects_malformed_mbid() -> None:
419 """A present but malformed MusicBrainz id fails validation, not just a missing one."""
420 provider = _provider()
421 assert (
422 provider._nfo_applies_cleanly(
423 {"title": "Album", "musicbrainzalbumid": "not-a-valid-uuid"}, "album"
424 )
425 is False
426 )
427 assert (
428 provider._nfo_applies_cleanly({"title": "Album", "musicbrainzalbumid": ALBUM_MBID}, "album")
429 is True
430 )
431
432
433def test_album_nfo_matches_absent_ids_falls_back_to_title() -> None:
434 """With no MusicBrainz ids at all, a title match is the deciding factor."""
435 root = {"title": "My Album"}
436 assert LocalFileSystemProvider._album_nfo_matches(root, None, None, "My Album") is True
437 assert LocalFileSystemProvider._album_nfo_matches(root, None, None, "Other Album") is False
438
439
440def test_album_nfo_matches_rejects_near_but_different_title() -> None:
441 """Title matching must be strict: a near-miss like 'Album 1' vs 'Album 2' is not a match."""
442 root = {"title": "Album 1"}
443 assert LocalFileSystemProvider._album_nfo_matches(root, None, None, "Album 2") is False
444
445
446def test_album_nfo_matches_rejects_conflicting_album_artist_mbid() -> None:
447 """A same-title NFO for a different album artist mbid must not resolve the folder."""
448 root = {"title": "Greatest Hits", "musicbrainzalbumartistid": OTHER_MBID}
449 assert (
450 LocalFileSystemProvider._album_nfo_matches(
451 root, None, None, "Greatest Hits", (ARTIST_MBID,)
452 )
453 is False
454 )
455 # a matching (or absent) album artist mbid still resolves via title
456 assert (
457 LocalFileSystemProvider._album_nfo_matches(root, None, None, "Greatest Hits", (OTHER_MBID,))
458 is True
459 )
460
461
462def test_album_nfo_matches_rejects_a_conflicting_edition_despite_identical_base_title() -> None:
463 """
464 Stripping each side's own edition suffix must not make two different editions equal.
465
466 "Album (Live)" and "Album (Remix)" both reduce to the base title "Album", but they name
467 two different, incompatible releases - the NFO must not resolve the folder for either.
468 """
469 root = {"title": "Album (Remix)"}
470 album_name, album_version = parse_title_and_version("Album (Live)")
471 assert (
472 LocalFileSystemProvider._album_nfo_matches(
473 root, None, None, album_name, album_version=album_version
474 )
475 is False
476 )
477
478
479def test_album_nfo_matches_absent_edition_stays_inconclusive() -> None:
480 """A plain, edition-less title on either side never blocks an otherwise matching title."""
481 root = {"title": "Album (Live)"}
482 # the track's own tag carries no edition of its own: still resolves via the title
483 assert LocalFileSystemProvider._album_nfo_matches(root, None, None, "Album") is True
484 # the NFO's edition still applies to the resolved album regardless
485 root = {"title": "Album"}
486 album_name, album_version = parse_title_and_version("Album (Live)")
487 assert (
488 LocalFileSystemProvider._album_nfo_matches(
489 root, None, None, album_name, album_version=album_version
490 )
491 is True
492 )
493
494
495async def test_album_resolves_via_title_match_despite_differently_cased_album_artist_mbid() -> None:
496 """The album-artist mbid conflict check must canonicalize both sides before comparing."""
497 provider = _provider()
498 track_dir = "Artist/CAT-1234"
499 _mock_single_file(
500 provider,
501 "Artist/CAT-1234/album.nfo",
502 f"<album><title>My Album</title>"
503 f"<musicbrainzalbumartistid>{ARTIST_MBID}</musicbrainzalbumartistid></album>".encode(),
504 )
505 result = await provider._resolve_album_dir_via_nfo(
506 track_dir, _tags(album_artist_ids=(ARTIST_MBID.upper(),))
507 )
508 assert result is not None
509 assert result[0] == track_dir
510
511
512def test_artist_nfo_matches_prefers_mbid_over_name() -> None:
513 """A present, matching artist mbid is sufficient even without checking the name."""
514 root = {"musicbrainzartistid": ARTIST_MBID}
515 assert LocalFileSystemProvider._artist_nfo_matches(root, "Anything", ARTIST_MBID) is True
516 assert LocalFileSystemProvider._artist_nfo_matches(root, "Anything", OTHER_MBID) is False
517
518
519def test_artist_nfo_matches_rejects_near_but_different_name() -> None:
520 """Name matching must be strict: a near-miss like 'Artist 1' vs 'Artist 2' is not a match."""
521 root = {"title": "Artist 1"}
522 assert LocalFileSystemProvider._artist_nfo_matches(root, "Artist 2", None) is False
523
524
525# --- get_artist refresh reaches the NFO fallback for a synthetic (no-path) artist ---------
526
527
528async def test_get_artist_refresh_anchors_on_representative_track_for_synthetic_artist() -> None:
529 """Refreshing a synthetic (path-less) artist still attempts resolution via its own track."""
530 provider = _provider()
531 db_artist = MagicMock(
532 item_id="1",
533 name="The Artist",
534 sort_name=None,
535 mbid=None,
536 provider_mappings=[MagicMock(provider_instance=INSTANCE_ID, url=None)],
537 )
538 provider.mass.music.artists.get_library_item_by_prov_id = AsyncMock(return_value=db_artist)
539 provider.exists = AsyncMock(return_value=False)
540 provider._resolve_artist_representative_track = AsyncMock(
541 return_value="Music/The Artist/Album/track.mp3"
542 )
543 parsed_artist = MagicMock()
544 provider._parse_artist = AsyncMock(return_value=parsed_artist)
545
546 result = await provider.get_artist("The Artist")
547
548 assert result is parsed_artist
549 provider._parse_artist.assert_awaited_once()
550 _args, kwargs = provider._parse_artist.await_args
551 assert kwargs["album_dir"] == "Music/The Artist/Album"
552 assert kwargs["representative_track"] == "Music/The Artist/Album/track.mp3"
553
554
555async def test_get_artist_refresh_returns_db_artist_with_no_track_to_anchor_on() -> None:
556 """A synthetic artist with no track of its own at all still just returns the db item."""
557 provider = _provider()
558 db_artist = MagicMock(
559 item_id="1",
560 name="The Artist",
561 provider_mappings=[MagicMock(provider_instance=INSTANCE_ID, url=None)],
562 )
563 provider.mass.music.artists.get_library_item_by_prov_id = AsyncMock(return_value=db_artist)
564 provider.exists = AsyncMock(return_value=False)
565 provider._resolve_artist_representative_track = AsyncMock(return_value=None)
566 provider._parse_artist = AsyncMock()
567
568 result = await provider.get_artist("The Artist")
569
570 assert result is db_artist
571
572
573async def test_get_artist_mappingless_refetch_uses_folder_basename_as_name() -> None:
574 """
575 The stateless second fetch of a just-resolved artist never leaks the full path as name.
576
577 A manual "Refresh item" re-fetches the artist by its new (resolved) id before that mapping
578 is persisted, so this id-only lookup finds no db item yet. With no NFO mbid and no matching
579 library artist to recover identity from, the folder's basename (not its full, possibly
580 nested, path) is the display name until an artist.nfo title (if any) overrides it inside
581 ``_parse_artist``.
582 """
583 provider = _provider()
584 provider.mass.music.artists.get_library_item_by_prov_id = AsyncMock(return_value=None)
585 provider.exists = AsyncMock(return_value=True)
586 provider._scandir = AsyncMock(return_value=[]) # no artist.nfo to recover an mbid from
587 provider.mass.music.artists.iter_library_items = _async_iter([])
588 parsed_artist = MagicMock()
589 provider._parse_artist = AsyncMock(return_value=parsed_artist)
590
591 result = await provider.get_artist("Various Artists/CAT-1234")
592
593 assert result is parsed_artist
594 args, kwargs = provider._parse_artist.await_args
595 assert args[0] == "CAT-1234"
596 assert kwargs["artist_path"] == "Various Artists/CAT-1234"
597
598
599async def test_get_artist_mappingless_refetch_recovers_identity_from_mbid_only_nfo() -> None:
600 """
601 An mbid-only artist.nfo must recover the real name, not leak the folder basename.
602
603 This is the same stateless second fetch as above, but the resolved folder's own
604 artist.nfo carries only a `musicbrainzartistid` (no title/name), which `parse_artist_nfo`
605 can't use to fix up a wrong name after the fact. The already-known library artist behind
606 that mbid is looked up instead, so its real name/sort_name survive the refetch.
607 """
608 provider = _provider()
609 provider.mass.music.artists.get_library_item_by_prov_id = AsyncMock(return_value=None)
610 provider.exists = AsyncMock(return_value=True)
611 provider._scandir = AsyncMock(return_value=[_item("CAT-1234/artist.nfo")])
612 provider._read_file = AsyncMock(
613 return_value=f"<artist><musicbrainzartistid>{ARTIST_MBID}</musicbrainzartistid></artist>".encode()
614 )
615 library_artist = MagicMock(sort_name="Real Artist, The")
616 library_artist.name = "The Real Artist"
617 provider.mass.music.artists.get_library_item_by_external_id = AsyncMock(
618 return_value=library_artist
619 )
620 parsed_artist = MagicMock()
621 provider._parse_artist = AsyncMock(return_value=parsed_artist)
622
623 result = await provider.get_artist("CAT-1234")
624
625 assert result is parsed_artist
626 provider.mass.music.artists.get_library_item_by_external_id.assert_awaited_once_with(
627 ARTIST_MBID, ExternalID.MB_ARTIST
628 )
629 _args, kwargs = provider._parse_artist.await_args
630 assert kwargs == {
631 "sort_name": "Real Artist, The",
632 "mbid": ARTIST_MBID,
633 "artist_path": "CAT-1234",
634 }
635 assert provider._parse_artist.await_args.args == ("The Real Artist",)
636
637
638async def test_get_artist_mappingless_refetch_recovers_identity_via_sort_name_alias() -> None:
639 """
640 A sort-name-alias folder match must recover the real name, not the folder's own basename.
641
642 A normal folder/sort-name-alias match (not an artist.nfo) carries no mbid to recover
643 identity from; the one already-known library artist whose name or sort-name matches this
644 folder is looked up instead, so the second, not-yet-persisted fetch doesn't rename it.
645 """
646 provider = _provider()
647 provider.mass.music.artists.get_library_item_by_prov_id = AsyncMock(return_value=None)
648 provider.exists = AsyncMock(return_value=True)
649 provider._scandir = AsyncMock(return_value=[]) # no artist.nfo in this folder
650 library_artist = MagicMock(sort_name="Beatles, The", mbid=None)
651 library_artist.name = "The Beatles"
652 provider.mass.music.artists.iter_library_items = _async_iter([library_artist])
653 parsed_artist = MagicMock()
654 provider._parse_artist = AsyncMock(return_value=parsed_artist)
655
656 result = await provider.get_artist("Music/Beatles, The")
657
658 assert result is parsed_artist
659 _args, kwargs = provider._parse_artist.await_args
660 assert kwargs == {
661 "sort_name": "Beatles, The",
662 "mbid": None,
663 "artist_path": "Music/Beatles, The",
664 }
665 assert provider._parse_artist.await_args.args == ("The Beatles",)
666
667
668async def test_get_artist_mappingless_refetch_ignores_ambiguous_folder_name_match() -> None:
669 """Two library artists matching the same folder name is never a safe positive identity."""
670 provider = _provider()
671 provider.mass.music.artists.get_library_item_by_prov_id = AsyncMock(return_value=None)
672 provider.exists = AsyncMock(return_value=True)
673 provider._scandir = AsyncMock(return_value=[])
674 ambiguous_a = MagicMock(sort_name=None)
675 ambiguous_a.name = "Beatles, The"
676 ambiguous_b = MagicMock(sort_name=None)
677 ambiguous_b.name = "Beatles, The"
678 provider.mass.music.artists.iter_library_items = _async_iter([ambiguous_a, ambiguous_b])
679 parsed_artist = MagicMock()
680 provider._parse_artist = AsyncMock(return_value=parsed_artist)
681
682 result = await provider.get_artist("Music/Beatles, The")
683
684 assert result is parsed_artist
685 _args, kwargs = provider._parse_artist.await_args
686 assert kwargs["sort_name"] is None
687 assert kwargs["mbid"] is None
688 assert provider._parse_artist.await_args.args == ("Beatles, The",)
689
690
691# --- sync-index lookup vs on-demand ------------------------------------------------------
692
693
694async def test_nfo_item_for_uses_sync_index_during_a_sync() -> None:
695 """Once the sync's NFO index is ready, the lookup is pure, never a filesystem probe."""
696 provider = _provider()
697 provider.sync_running = True
698 provider._sync_nfo_index_ready = True
699 nfo_item = _item("Artist/Album/album.nfo")
700 provider._sync_nfo_by_dir = {"Artist/Album": {"album.nfo": nfo_item}}
701 provider._scandir = AsyncMock(side_effect=AssertionError("must not touch the filesystem"))
702 result = await provider._nfo_item_for("Artist/Album", "album.nfo")
703 assert result is nfo_item
704 result = await provider._nfo_item_for("Artist/Other", "album.nfo")
705 assert result is None
706
707
708async def test_nfo_item_for_bypasses_a_concurrent_syncs_index_during_a_forced_refresh() -> None:
709 """
710 A forced refresh never joins a concurrently running sync's provider-wide index.
711
712 `_sync_nfo_index_ready` is set once for the whole provider, not scoped to one request; a
713 manual "Refresh item" racing an unrelated background sync must still see a live listing
714 instead of that sync's own (possibly already-stale) snapshot.
715 """
716 provider = _provider()
717 provider.sync_running = True
718 provider._sync_nfo_index_ready = True
719 provider._sync_nfo_by_dir = {"Artist/Album": {"album.nfo": _item("Artist/Album/stale.nfo")}}
720 fresh_item = _item("Artist/Album/album.nfo")
721 provider._scandir = AsyncMock(return_value=[fresh_item])
722
723 token = BYPASS_CACHE.set(True)
724 try:
725 result = await provider._nfo_item_for("Artist/Album", "album.nfo")
726 finally:
727 BYPASS_CACHE.reset(token)
728
729 assert result is fresh_item
730
731
732async def test_ondemand_listing_scope_memoizes_during_a_forced_refresh_racing_a_sync() -> None:
733 """
734 A forced refresh gets its own memo even while a concurrent sync's index is ready.
735
736 Otherwise a refresh touching several candidate folders (e.g. an artist's ancestor levels)
737 would repeat every listing once per lookup instead of once for the whole parse, since
738 `_nfo_item_for` never takes the sync's index shortcut during a refresh in the first place.
739 """
740 provider = _provider()
741 provider.sync_running = True
742 provider._sync_nfo_index_ready = True
743 nfo_item = _item("Artist/Album/album.nfo")
744 provider._scandir = AsyncMock(return_value=[nfo_item])
745
746 token = BYPASS_CACHE.set(True)
747 try:
748 with provider._ondemand_listing_scope():
749 first = await provider._nfo_item_for("Artist/Album", "album.nfo")
750 second = await provider._nfo_item_for("Artist/Album", "artist.nfo")
751 finally:
752 BYPASS_CACHE.reset(token)
753
754 assert first is nfo_item
755 assert second is None
756 provider._scandir.assert_awaited_once()
757
758
759async def test_nfo_item_for_reuses_the_sync_batch_scope_while_the_index_is_unready() -> None:
760 """
761 While the sync index isn't trusted, a folder shared by several tracks is listed once.
762
763 This is the fallback used before the walk completes (or, permanently for that sync,
764 after an incomplete scan): `sync_library` wraps its whole track-processing batch in one
765 shared `_ondemand_listing_scope`, so this reuses the same per-parse memo used outside a
766 sync, rather than a fresh listing per track sharing the folder.
767 """
768 provider = _provider()
769 provider.sync_running = True
770 provider._sync_nfo_index_ready = False
771 nfo_item = _item("Artist/Album/album.nfo")
772 provider._scandir = AsyncMock(return_value=[nfo_item])
773
774 with provider._ondemand_listing_scope():
775 first = await provider._nfo_item_for("Artist/Album", "album.nfo")
776 second = await provider._nfo_item_for("Artist/Album", "artist.nfo")
777
778 assert first is nfo_item
779 assert second is None
780 provider._scandir.assert_awaited_once()
781
782
783async def test_nfo_item_for_memoizes_on_demand_lookups() -> None:
784 """Outside a sync, repeated lookups for the same candidate folder list it only once."""
785 provider = _provider()
786 nfo_item = _item("Artist/Album/album.nfo")
787 provider._scandir = AsyncMock(return_value=[nfo_item])
788 with provider._ondemand_listing_scope():
789 first = await provider._nfo_item_for("Artist/Album", "album.nfo")
790 second = await provider._nfo_item_for("Artist/Album", "artist.nfo")
791 assert first is nfo_item
792 assert second is None
793 provider._scandir.assert_awaited_once()
794
795
796# --- refresh reachability after an id-changing resolution ---------------------------------
797
798
799async def test_get_album_tracks_falls_back_to_folder_scan_without_a_db_mapping() -> None:
800 """
801 A folder not yet mapped in the library is scanned directly for tracks.
802
803 This is the second, id-changed fetch of a manual "Refresh item" that has just resolved a
804 previously synthetic album onto its real folder, before that mapping is persisted.
805 """
806 provider = _provider()
807 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
808 provider.exists = AsyncMock(return_value=True)
809 track_item = _item("Artist/Album/01 Track.flac")
810 provider._scandir = AsyncMock(return_value=[track_item])
811 parsed_track = MagicMock(
812 album=Album(
813 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
814 )
815 )
816 provider._parse_track = AsyncMock(return_value=parsed_track)
817
818 with patch(
819 "music_assistant.providers.filesystem_local.async_parse_tags",
820 AsyncMock(return_value=MagicMock()),
821 ):
822 result = await provider.get_album_tracks("Artist/Album")
823
824 assert result == [parsed_track]
825
826
827async def test_get_album_tracks_sorts_a_mappingless_result_by_disc_and_track_number() -> None:
828 """
829 A mappingless result is sorted, since a WebDAV/cloud folder listing order isn't guaranteed.
830
831 The albums controller returns this provider's list unchanged when there is no library
832 album yet, so an out-of-order (or reversed) listing must be sorted here.
833 """
834 provider = _provider()
835 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
836 provider.exists = AsyncMock(return_value=True)
837 good_album = Album(
838 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
839 )
840 track_items = [_item(f"Artist/Album/{n:02d} Track.flac") for n in (3, 1, 2)]
841 provider._scandir = AsyncMock(return_value=track_items)
842 parsed_tracks = {
843 3: MagicMock(album=good_album, disc_number=1, track_number=3),
844 1: MagicMock(album=good_album, disc_number=1, track_number=1),
845 2: MagicMock(album=good_album, disc_number=1, track_number=2),
846 }
847
848 async def _parse_track_side_effect(item: FileSystemItem, _tags: Any) -> Any:
849 track_number = int(item.filename.split(" ", 1)[0])
850 return parsed_tracks[track_number]
851
852 provider._parse_track = AsyncMock(side_effect=_parse_track_side_effect)
853
854 with patch(
855 "music_assistant.providers.filesystem_local.async_parse_tags",
856 AsyncMock(return_value=MagicMock()),
857 ):
858 result = await provider.get_album_tracks("Artist/Album")
859
860 assert [track.track_number for track in result] == [1, 2, 3]
861
862
863async def test_get_album_tracks_raises_when_folder_is_genuinely_missing() -> None:
864 """A folder that has no library mapping and does not exist on disk still raises."""
865 provider = _provider()
866 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
867 provider.exists = AsyncMock(return_value=False)
868
869 with pytest.raises(MediaNotFoundError, match="Album not found"):
870 await provider.get_album_tracks("Artist/Album")
871
872
873async def test_parse_artist_enrichment_matches_nfo_case_insensitively() -> None:
874 """Direct-path artist parsing (bypassing resolution) still finds a case-variant NFO."""
875 provider = _provider()
876 provider.manifest = MagicMock(domain="filesystem_local")
877 provider.exists = AsyncMock(return_value=True)
878 provider._scandir = AsyncMock(return_value=[_item("Artist/ARTIST.NFO")])
879 provider._read_file = AsyncMock(return_value=b"<artist><title>The Artist</title></artist>")
880 provider._get_local_images = AsyncMock(return_value=[])
881 provider.cache.get = AsyncMock(return_value=None)
882 provider.cache.set = AsyncMock()
883
884 artist = await provider._parse_artist("The Artist", artist_path="Artist")
885
886 assert artist.name == "The Artist"
887
888
889async def test_parse_artist_ancestor_plain_name_outranks_a_root_sort_name_alias() -> None:
890 """
891 A root-level sort-name folder must not outrank a nearer ancestor plain-name match.
892
893 The plain name is tried at every location (root, then ancestor) before the sort-name
894 alias is tried anywhere, mirroring the precedence already enforced within a single
895 location by `get_artist_dir`/`get_album_dir`.
896 """
897 provider = _provider()
898 provider.manifest = MagicMock(domain="filesystem_local")
899 # "Beatles, The" (the sort-name alias) exists at the provider root; "The Beatles" (the
900 # plain, exact name) does not exist at the root, but does exist as the real ancestor
901 # folder one level up from the album
902 provider.exists = AsyncMock(side_effect=lambda path: path == "Beatles, The")
903 provider._scandir = AsyncMock(return_value=[])
904 provider._get_local_images = AsyncMock(return_value=[])
905 provider.cache.get = AsyncMock(return_value=None)
906 provider.cache.set = AsyncMock()
907
908 artist = await provider._parse_artist(
909 "The Beatles",
910 sort_name="Beatles, The",
911 album_dir="Beatles, The/The Beatles/Album",
912 )
913
914 assert artist.item_id == "Beatles, The/The Beatles"
915
916
917async def test_parse_artist_validated_nfo_outranks_sort_name_alias_normal_match() -> None:
918 """
919 A validated artist.nfo now outranks a sort-name alias found through ordinary matching.
920
921 An exact (normalized) plain-name match is tried first; once that finds nothing, the
922 bounded artist.nfo fallback is attempted before any relaxed heuristic - including the
923 sort-name alias, itself a relaxed/fuzzy guess - so a folder found only via the alias
924 must not win over a validated NFO elsewhere.
925 """
926 provider = _provider()
927 provider.manifest = MagicMock(domain="filesystem_local")
928 # "Beatles, The" (the sort-name alias) exists at the provider root; the album lives
929 # elsewhere, under an ancestor whose own artist.nfo identifies "The Beatles" by plain
930 # name - that validated NFO now wins over the alias's ordinary (non-NFO) root match
931 provider.exists = AsyncMock(side_effect=lambda path: path == "Beatles, The")
932 _mock_single_file(
933 provider, "Various/artist.nfo", b"<artist><title>The Beatles</title></artist>"
934 )
935 provider._get_local_images = AsyncMock(return_value=[])
936 provider.cache.get = AsyncMock(return_value=None)
937 provider.cache.set = AsyncMock()
938
939 artist = await provider._parse_artist(
940 "The Beatles",
941 sort_name="Beatles, The",
942 album_dir="Various/Album",
943 )
944
945 assert artist.item_id == "Various"
946 provider._read_file.assert_awaited_once()
947
948
949async def test_parse_artist_relaxed_match_never_trusts_a_folder_the_nfo_tier_rejected() -> None:
950 """
951 A relaxed match landing on a folder the NFO tier already rejected must not trust it.
952
953 The bounded validated artist.nfo fallback reads and rejects "Music/Beatles, The"'s own
954 artist.nfo (it names a different artist entirely). The sort-name alias then matches that
955 exact same folder through ordinary (non-NFO) matching - the rejected file must not be
956 silently re-applied during enrichment just because the folder matched some other way.
957 """
958 provider = _provider()
959 provider.manifest = MagicMock(domain="filesystem_local")
960 # only the ancestor folder itself exists (the one the NFO tier rejects and the sort-name
961 # alias later matches); no root-level shortcut for either candidate name
962 provider.exists = AsyncMock(side_effect=lambda path: path == "Music/Beatles, The")
963 _mock_single_file(
964 provider, "Music/Beatles, The/artist.nfo", b"<artist><title>Someone Else</title></artist>"
965 )
966 provider._get_local_images = AsyncMock(return_value=[])
967 provider.cache.get = AsyncMock(return_value=None)
968 provider.cache.set = AsyncMock()
969
970 async def _empty_iter(*_args: object, **_kwargs: object) -> Any:
971 return
972 yield # pragma: no cover - makes this an async generator
973
974 provider.mass.music.artists.iter_library_items = _empty_iter
975
976 artist = await provider._parse_artist(
977 "The Beatles",
978 sort_name="Beatles, The",
979 album_dir="Music/Beatles, The/Album",
980 )
981
982 # resolved via the sort-name alias, but never renamed from the rejected NFO's own title
983 assert artist.item_id == "Music/Beatles, The"
984 assert artist.name == "The Beatles"
985
986
987async def test_parse_artist_exact_plain_name_match_skips_nfo_resolution() -> None:
988 """An exact (normalized) plain-name folder match wins outright; NFO is never consulted."""
989 provider = _provider()
990 provider.manifest = MagicMock(domain="filesystem_local")
991 # only the true ancestor folder "Music/The Artist" exists at all
992 provider.exists = AsyncMock(side_effect=lambda path: path == "Music/The Artist")
993 provider._scandir = AsyncMock(return_value=[])
994 provider._get_local_images = AsyncMock(return_value=[])
995 provider.cache.get = AsyncMock(return_value=None)
996 provider.cache.set = AsyncMock()
997 provider._resolve_artist_dir_via_nfo = AsyncMock(
998 return_value=(
999 "Music/Someone Else",
1000 _item("Music/Someone Else/artist.nfo"),
1001 {"title": "The Artist"},
1002 )
1003 )
1004
1005 artist = await provider._parse_artist("The Artist", album_dir="Music/The Artist/Album")
1006
1007 assert artist.item_id == "Music/The Artist"
1008 provider._resolve_artist_dir_via_nfo.assert_not_awaited()
1009
1010
1011async def test_parse_artist_malformed_nfo_falls_through_to_relaxed_sort_name_match() -> None:
1012 """A malformed/non-matching artist.nfo leaves the sort-name alias as the last resort."""
1013 provider = _provider()
1014 provider.manifest = MagicMock(domain="filesystem_local")
1015 # "Beatles, The" (the sort-name alias) exists at the provider root; the album's ancestor
1016 # has its own artist.nfo, but it names a different artist entirely
1017 provider.exists = AsyncMock(side_effect=lambda path: path == "Beatles, The")
1018 _mock_single_file(
1019 provider, "Various/artist.nfo", b"<artist><title>Somebody Else</title></artist>"
1020 )
1021 provider._get_local_images = AsyncMock(return_value=[])
1022 provider.cache.get = AsyncMock(return_value=None)
1023 provider.cache.set = AsyncMock()
1024
1025 artist = await provider._parse_artist(
1026 "The Beatles",
1027 sort_name="Beatles, The",
1028 album_dir="Various/Album",
1029 )
1030
1031 # the mismatching NFO was rejected; the sort-name alias resolved it instead
1032 assert artist.item_id == "Beatles, The"
1033 # tried once against each candidate (name, then sort-name alias); no root cache to
1034 # single-flight the second attempt against the same file
1035 assert provider._read_file.await_count == 2
1036
1037
1038async def test_get_album_tracks_skips_a_malformed_sibling_track() -> None:
1039 """One sibling file whose tags cannot be read is skipped, not fatal to the folder scan."""
1040 provider = _provider()
1041 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1042 provider.exists = AsyncMock(return_value=True)
1043 good_item = _item("Artist/Album/01 Track.flac")
1044 bad_item = _item("Artist/Album/02 Track.flac")
1045 provider._scandir = AsyncMock(return_value=[bad_item, good_item])
1046 parsed_track = MagicMock(
1047 album=Album(
1048 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1049 )
1050 )
1051 provider._parse_track = AsyncMock(return_value=parsed_track)
1052
1053 async def _parse_tags_side_effect(absolute_path: str, _file_size: int) -> Any:
1054 if absolute_path == bad_item.absolute_path:
1055 raise InvalidDataError("corrupt file")
1056 return MagicMock()
1057
1058 with patch(
1059 "music_assistant.providers.filesystem_local.async_parse_tags",
1060 AsyncMock(side_effect=_parse_tags_side_effect),
1061 ):
1062 result = await provider.get_album_tracks("Artist/Album")
1063
1064 assert result == [parsed_track]
1065
1066
1067def test_build_nfo_index_skips_folders_with_only_non_nfo_metadata_files() -> None:
1068 """
1069 An image-only folder must not get an (empty) entry in the sync's NFO index.
1070
1071 ``metadata_files`` also carries folder artwork; a folder with cover art but no
1072 album.nfo/artist.nfo must be absent from the index entirely, not present with an empty
1073 per-directory map, or a large image-heavy library would retain a library-sized index.
1074 """
1075 nfo_item = _item("Artist/Album/album.nfo")
1076 image_item = _item("Artist/Album/folder.jpg")
1077 image_only_item = _item("Artist/OtherAlbum/cover.jpg")
1078
1079 index = LocalFileSystemProvider._build_nfo_index([nfo_item, image_item, image_only_item])
1080
1081 assert index == {"Artist/Album": {"album.nfo": nfo_item}}
1082 assert "Artist/OtherAlbum" not in index
1083
1084
1085async def test_get_album_reuses_the_already_parsed_album_from_the_folder_scan() -> None:
1086 """The mappingless refresh path never re-resolves/re-parses the same representative file."""
1087 provider = _provider()
1088 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1089 provider.exists = AsyncMock(return_value=True)
1090 track_item = _item("Artist/Album/01 Track.flac")
1091 provider._scandir = AsyncMock(return_value=[track_item])
1092 parsed_album = Album(
1093 item_id="Artist/Album", provider=INSTANCE_ID, name="My Album", provider_mappings=set()
1094 )
1095 parsed_track = MagicMock(album=parsed_album)
1096 provider._parse_track = AsyncMock(return_value=parsed_track)
1097 provider.resolve = AsyncMock(side_effect=AssertionError("must not re-resolve the same file"))
1098
1099 with patch(
1100 "music_assistant.providers.filesystem_local.async_parse_tags",
1101 AsyncMock(return_value=MagicMock()),
1102 ):
1103 result = await provider.get_album("Artist/Album")
1104
1105 assert result is parsed_album
1106 provider._parse_track.assert_awaited_once()
1107
1108
1109async def test_get_album_closes_the_track_scan_deterministically_on_early_return() -> None:
1110 """
1111 Returning early from `get_album` still closes its underlying scan generator right away.
1112
1113 Otherwise the generator's `_ondemand_listing_scope()` cleanup (a ContextVar reset) is left
1114 to whenever the event loop's async-generator finalizer happens to run, instead of
1115 deterministically, right when `get_album` returns.
1116 """
1117 provider = _provider()
1118 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1119 provider.exists = AsyncMock(return_value=True)
1120 track_item = _item("Artist/Album/01 Track.flac")
1121 provider._scandir = AsyncMock(return_value=[track_item])
1122 parsed_album = Album(
1123 item_id="Artist/Album", provider=INSTANCE_ID, name="My Album", provider_mappings=set()
1124 )
1125 provider._parse_track = AsyncMock(return_value=MagicMock(album=parsed_album))
1126
1127 with patch(
1128 "music_assistant.providers.filesystem_local.async_parse_tags",
1129 AsyncMock(return_value=MagicMock()),
1130 ):
1131 await provider.get_album("Artist/Album")
1132
1133 # the on-demand memo must already be torn down, synchronously, not left for GC to close
1134 assert _ONDEMAND_NFO_ITEMS.get() is None
1135
1136
1137async def test_get_album_tracks_skips_a_leading_track_without_an_album() -> None:
1138 """A parseable file with no album tag is skipped, not returned as a false representative."""
1139 provider = _provider()
1140 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1141 provider.exists = AsyncMock(return_value=True)
1142 no_album_item = _item("Artist/Album/00 Intro.flac")
1143 good_item = _item("Artist/Album/01 Track.flac")
1144 provider._scandir = AsyncMock(return_value=[no_album_item, good_item])
1145 good_album = Album(
1146 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1147 )
1148 no_album_track = MagicMock(album=None)
1149 good_track = MagicMock(album=good_album)
1150
1151 async def _parse_track_side_effect(item: FileSystemItem, _tags: Any) -> Any:
1152 return no_album_track if item is no_album_item else good_track
1153
1154 provider._parse_track = AsyncMock(side_effect=_parse_track_side_effect)
1155
1156 with patch(
1157 "music_assistant.providers.filesystem_local.async_parse_tags",
1158 AsyncMock(return_value=MagicMock()),
1159 ):
1160 result = await provider.get_album_tracks("Artist/Album")
1161
1162 assert result == [good_track]
1163
1164
1165async def test_get_album_tracks_propagates_a_transient_failure_from_parse_track() -> None:
1166 """A transient failure while building the full track (e.g. NFO I/O) propagates for retry."""
1167 provider = _provider()
1168 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1169 provider.exists = AsyncMock(return_value=True)
1170 track_item = _item("Artist/Album/01 Track.flac")
1171 provider._scandir = AsyncMock(return_value=[track_item])
1172 provider._parse_track = AsyncMock(side_effect=OSError("network hiccup"))
1173
1174 with (
1175 patch(
1176 "music_assistant.providers.filesystem_local.async_parse_tags",
1177 AsyncMock(return_value=MagicMock()),
1178 ),
1179 pytest.raises(OSError, match="network hiccup"),
1180 ):
1181 await provider.get_album_tracks("Artist/Album")
1182
1183
1184async def test_get_album_tracks_ignores_a_track_resolving_to_a_different_folder() -> None:
1185 """A stray/mis-tagged file whose own identity resolves elsewhere is never mistaken for this folder."""
1186 provider = _provider()
1187 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1188 provider.exists = AsyncMock(return_value=True)
1189 stray_item = _item("Artist/Album/00 Stray.flac")
1190 good_item = _item("Artist/Album/01 Track.flac")
1191 provider._scandir = AsyncMock(return_value=[stray_item, good_item])
1192 other_album = Album(
1193 item_id="Other/Folder", provider=INSTANCE_ID, name="Other", provider_mappings=set()
1194 )
1195 good_album = Album(
1196 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1197 )
1198 stray_track = MagicMock(album=other_album)
1199 good_track = MagicMock(album=good_album)
1200
1201 async def _parse_track_side_effect(item: FileSystemItem, _tags: Any) -> Any:
1202 return stray_track if item is stray_item else good_track
1203
1204 provider._parse_track = AsyncMock(side_effect=_parse_track_side_effect)
1205
1206 with patch(
1207 "music_assistant.providers.filesystem_local.async_parse_tags",
1208 AsyncMock(return_value=MagicMock()),
1209 ):
1210 result = await provider.get_album_tracks("Artist/Album")
1211
1212 assert result == [good_track]
1213
1214
1215async def test_get_album_tracks_tolerates_an_invalid_cue_sheet() -> None:
1216 """An empty/invalid CUE sheet is skipped, not fatal to the rest of the folder scan."""
1217 provider = _provider()
1218 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1219 provider.exists = AsyncMock(return_value=True)
1220 bad_cue = _item("Artist/Album/bad.cue")
1221 good_item = _item("Artist/Album/01 Track.flac")
1222 provider._scandir = AsyncMock(return_value=[bad_cue, good_item])
1223 good_album = Album(
1224 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1225 )
1226 good_track = MagicMock(album=good_album)
1227 provider._parse_track = AsyncMock(return_value=good_track)
1228
1229 async def _cue_parse_tracks_side_effect(item: FileSystemItem) -> Any:
1230 if item is bad_cue:
1231 raise InvalidDataError("CUE sheet has no tracks")
1232 return []
1233
1234 provider._cue.parse_tracks = AsyncMock(side_effect=_cue_parse_tracks_side_effect)
1235 provider._cue.load_cue_sheet = AsyncMock(return_value=MagicMock(file_path=None))
1236 provider._cue.find_audio_file = AsyncMock(return_value="Artist/Album/audio.flac")
1237
1238 with patch(
1239 "music_assistant.providers.filesystem_local.async_parse_tags",
1240 AsyncMock(return_value=MagicMock()),
1241 ):
1242 result = await provider.get_album_tracks("Artist/Album")
1243
1244 assert result == [good_track]
1245
1246
1247async def test_get_album_tracks_propagates_a_transient_cue_album_resolution_failure() -> None:
1248 """A transient failure while resolving a CUE track's album (e.g. NFO I/O) propagates."""
1249 provider = _provider()
1250 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1251 provider.exists = AsyncMock(return_value=True)
1252 cue_item = _item("Artist/Album/album.cue")
1253 provider._scandir = AsyncMock(return_value=[cue_item])
1254 provider._cue.parse_tracks = AsyncMock(side_effect=OSError("network hiccup"))
1255 provider._cue.load_cue_sheet = AsyncMock(return_value=MagicMock(file_path=None))
1256 provider._cue.find_audio_file = AsyncMock(return_value="Artist/Album/audio.flac")
1257
1258 with pytest.raises(OSError, match="network hiccup"):
1259 await provider.get_album_tracks("Artist/Album")
1260
1261
1262async def test_get_album_tracks_propagates_media_not_found_from_cue_album_construction() -> None:
1263 """
1264 A `MediaNotFoundError` while constructing a CUE track's album must propagate too.
1265
1266 A cloud/WebDAV provider's own `_read_file` raises `MediaNotFoundError` for any failed
1267 read, not only a genuinely missing file, so this specific error type must not be treated
1268 as "this CUE is unreadable" once its companion audio file is already confirmed present.
1269 """
1270 provider = _provider()
1271 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1272 provider.exists = AsyncMock(return_value=True)
1273 cue_item = _item("Artist/Album/album.cue")
1274 provider._scandir = AsyncMock(return_value=[cue_item])
1275 provider._cue.load_cue_sheet = AsyncMock(return_value=MagicMock(file_path="album.flac"))
1276 provider._cue.find_audio_file = AsyncMock(return_value="Artist/Album/album.flac")
1277 provider._cue.parse_tracks = AsyncMock(
1278 side_effect=MediaNotFoundError("transient NFO file read failure")
1279 )
1280
1281 with pytest.raises(MediaNotFoundError, match="transient NFO file read failure"):
1282 await provider.get_album_tracks("Artist/Album")
1283
1284
1285async def test_get_album_tracks_propagates_a_non_tag_error_from_a_plain_track() -> None:
1286 """
1287 A transient storage failure while reading a plain track's tags is not "unreadable tags".
1288
1289 Only `InvalidDataError` (genuinely malformed tags) is treated as "skip this track";
1290 anything else - e.g. a cloud/WebDAV provider's own transient read failure - must
1291 propagate so the sync can retry instead of silently dropping the track.
1292 """
1293 provider = _provider()
1294 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1295 provider.exists = AsyncMock(return_value=True)
1296 track_item = _item("Artist/Album/01 Track.flac")
1297 provider._scandir = AsyncMock(return_value=[track_item])
1298
1299 with (
1300 patch(
1301 "music_assistant.providers.filesystem_local.async_parse_tags",
1302 AsyncMock(side_effect=MediaNotFoundError("transient read failure")),
1303 ),
1304 pytest.raises(MediaNotFoundError, match="transient read failure"),
1305 ):
1306 await provider.get_album_tracks("Artist/Album")
1307
1308
1309async def test_get_album_tracks_propagates_an_os_error_from_a_plain_track() -> None:
1310 """An `OSError` (e.g. ffprobe failing to launch) must propagate, not mark a track unreadable."""
1311 provider = _provider()
1312 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1313 provider.exists = AsyncMock(return_value=True)
1314 track_item = _item("Artist/Album/01 Track.flac")
1315 provider._scandir = AsyncMock(return_value=[track_item])
1316
1317 with (
1318 patch(
1319 "music_assistant.providers.filesystem_local.async_parse_tags",
1320 AsyncMock(side_effect=OSError("ffprobe not found")),
1321 ),
1322 pytest.raises(OSError, match="ffprobe not found"),
1323 ):
1324 await provider.get_album_tracks("Artist/Album")
1325
1326
1327async def test_get_album_tracks_skips_a_cue_with_missing_companion_audio() -> None:
1328 """A CUE sheet whose companion audio file cannot be found is skipped, not fatal."""
1329 provider = _provider()
1330 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1331 provider.exists = AsyncMock(return_value=True)
1332 cue_item = _item("Artist/Album/album.cue")
1333 good_item = _item("Artist/Album/01 Track.flac")
1334 provider._scandir = AsyncMock(return_value=[cue_item, good_item])
1335 provider._cue.load_cue_sheet = AsyncMock(return_value=MagicMock(file_path="missing.flac"))
1336 provider._cue.find_audio_file = AsyncMock(return_value=None)
1337 provider._cue.parse_tracks = AsyncMock(
1338 side_effect=AssertionError("must not attempt to fully parse a CUE with no companion")
1339 )
1340 good_album = Album(
1341 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1342 )
1343 good_track = MagicMock(album=good_album)
1344 provider._parse_track = AsyncMock(return_value=good_track)
1345
1346 with patch(
1347 "music_assistant.providers.filesystem_local.async_parse_tags",
1348 AsyncMock(return_value=MagicMock()),
1349 ):
1350 result = await provider.get_album_tracks("Artist/Album")
1351
1352 assert result == [good_track]
1353
1354
1355async def test_get_album_tracks_excludes_cue_companion_audio() -> None:
1356 """A CUE's companion audio file is never yielded as its own unsegmented track."""
1357 provider = _provider()
1358 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1359 provider.exists = AsyncMock(return_value=True)
1360 cue_item = _item("Artist/Album/album.cue")
1361 companion_item = _item("Artist/Album/album.flac")
1362 provider._scandir = AsyncMock(return_value=[cue_item, companion_item])
1363 good_album = Album(
1364 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1365 )
1366 cue_track = MagicMock(album=good_album)
1367 provider._cue.load_cue_sheet = AsyncMock(return_value=MagicMock(file_path="album.flac"))
1368 provider._cue.find_audio_file = AsyncMock(return_value="Artist/Album/album.flac")
1369 provider._cue.parse_tracks = AsyncMock(return_value=[cue_track])
1370 provider._parse_track = AsyncMock(
1371 side_effect=AssertionError("the companion audio must not be parsed as its own track")
1372 )
1373
1374 result = await provider.get_album_tracks("Artist/Album")
1375
1376 assert result == [cue_track]
1377
1378
1379async def test_get_album_tracks_processes_the_companion_of_a_track_less_cue_sheet() -> None:
1380 """
1381 A CUE sheet that parses cleanly but names no tracks must not exclude its companion.
1382
1383 `load_cue_sheet` never raises for malformed/truncated content with no TRACK lines - it
1384 just returns a track-less sheet - so the companion audio file must still be processed as
1385 a normal, standalone track instead of silently disappearing.
1386 """
1387 provider = _provider()
1388 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1389 provider.exists = AsyncMock(return_value=True)
1390 cue_item = _item("Artist/Album/album.cue")
1391 companion_item = _item("Artist/Album/album.flac")
1392 provider._scandir = AsyncMock(return_value=[cue_item, companion_item])
1393 good_album = Album(
1394 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1395 )
1396 good_track = MagicMock(album=good_album)
1397 provider._cue.load_cue_sheet = AsyncMock(
1398 return_value=MagicMock(file_path="album.flac", tracks=[])
1399 )
1400 provider._cue.parse_tracks = AsyncMock(
1401 side_effect=AssertionError("a track-less CUE sheet must not be parsed for tracks")
1402 )
1403 provider._parse_track = AsyncMock(return_value=good_track)
1404
1405 with patch(
1406 "music_assistant.providers.filesystem_local.async_parse_tags",
1407 AsyncMock(return_value=MagicMock()),
1408 ):
1409 result = await provider.get_album_tracks("Artist/Album")
1410
1411 assert result == [good_track]
1412
1413
1414async def test_get_album_tracks_excludes_a_cross_directory_cue_companion() -> None:
1415 """
1416 A CUE's companion audio is excluded even when it lives in a different subfolder.
1417
1418 One CUE at the album's top level can cover a companion audio file split across a "Disc 1"
1419 subfolder; that companion must still be recognized as absorbed once the subfolder itself
1420 is scanned, not parsed again there as an unsegmented duplicate track.
1421 """
1422 provider = _provider()
1423 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1424 provider.exists = AsyncMock(return_value=True)
1425 cue_item = _item("Artist/Album/album.cue")
1426 disc_dir = _item("Artist/Album/Disc 1")
1427 disc_dir.is_dir = True
1428 companion_item = _item("Artist/Album/Disc 1/album.flac")
1429 good_album = Album(
1430 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1431 )
1432 cue_track = MagicMock(album=good_album)
1433 provider._cue.load_cue_sheet = AsyncMock(return_value=MagicMock(file_path="Disc 1/album.flac"))
1434 provider._cue.find_audio_file = AsyncMock(return_value="Artist/Album/Disc 1/album.flac")
1435 provider._cue.parse_tracks = AsyncMock(return_value=[cue_track])
1436 provider._parse_track = AsyncMock(
1437 side_effect=AssertionError(
1438 "the cross-directory companion audio must not be parsed as its own track"
1439 )
1440 )
1441
1442 async def _scandir_side_effect(scan_folder: str) -> list[FileSystemItem]:
1443 if scan_folder == "Artist/Album":
1444 return [cue_item, disc_dir]
1445 if scan_folder == "Artist/Album/Disc 1":
1446 return [companion_item]
1447 return []
1448
1449 provider._scandir = AsyncMock(side_effect=_scandir_side_effect)
1450
1451 with patch(
1452 "music_assistant.providers.filesystem_local.async_parse_tags",
1453 AsyncMock(
1454 side_effect=AssertionError(
1455 "the cross-directory companion audio must not be tag-parsed either"
1456 )
1457 ),
1458 ):
1459 result = await provider.get_album_tracks("Artist/Album")
1460
1461 assert result == [cue_track]
1462
1463
1464async def test_get_album_tracks_scans_arbitrarily_named_subfolder() -> None:
1465 """
1466 An arbitrarily named subfolder (not a regex-recognized disc dir) is still scanned.
1467
1468 An album resolved via NFO onto an oddly named parent folder may have all of its tracks in a
1469 subfolder that normal disc-folder detection cannot recognize (e.g. "weird-disc-name").
1470 """
1471 provider = _provider()
1472 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1473 provider.exists = AsyncMock(return_value=True)
1474 subfolder = _item("Artist/Album/weird-disc-name")
1475 subfolder.is_dir = True
1476 track_item = _item("Artist/Album/weird-disc-name/01 Track.flac")
1477 good_album = Album(
1478 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1479 )
1480 good_track = MagicMock(album=good_album)
1481
1482 async def _scandir_side_effect(scan_folder: str) -> list[FileSystemItem]:
1483 if scan_folder == "Artist/Album":
1484 return [subfolder]
1485 if scan_folder == "Artist/Album/weird-disc-name":
1486 return [track_item]
1487 return []
1488
1489 provider._scandir = AsyncMock(side_effect=_scandir_side_effect)
1490 provider._parse_track = AsyncMock(return_value=good_track)
1491
1492 with patch(
1493 "music_assistant.providers.filesystem_local.async_parse_tags",
1494 AsyncMock(return_value=MagicMock()),
1495 ):
1496 result = await provider.get_album_tracks("Artist/Album")
1497
1498 assert result == [good_track]
1499
1500
1501async def test_get_album_never_lists_a_subfolder_when_the_root_already_satisfies_it() -> None:
1502 """
1503 `get_album` (needing just one track) must not pay for a subfolder listing it never uses.
1504
1505 Each folder listing is a remote round trip for a cloud/WebDAv-backed provider, so a caller
1506 that stops as soon as it finds a usable track in the root should never cause a subfolder to
1507 be listed at all.
1508 """
1509 provider = _provider()
1510 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1511 provider.exists = AsyncMock(return_value=True)
1512 root_track_item = _item("Artist/Album/01 Track.flac")
1513 subfolder = _item("Artist/Album/Disc 2")
1514 subfolder.is_dir = True
1515 good_album = Album(
1516 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1517 )
1518 good_track = MagicMock(album=good_album)
1519
1520 async def _scandir_side_effect(scan_folder: str) -> list[FileSystemItem]:
1521 if scan_folder == "Artist/Album":
1522 return [root_track_item, subfolder]
1523 raise AssertionError(f"must not list the subfolder {scan_folder!r}")
1524
1525 provider._scandir = AsyncMock(side_effect=_scandir_side_effect)
1526 provider._parse_track = AsyncMock(return_value=good_track)
1527
1528 with patch(
1529 "music_assistant.providers.filesystem_local.async_parse_tags",
1530 AsyncMock(return_value=MagicMock()),
1531 ):
1532 result = await provider.get_album("Artist/Album")
1533
1534 assert result is good_album
1535
1536
1537async def test_get_album_tracks_shares_one_listing_memo_across_the_whole_scan() -> None:
1538 """A mappingless multi-track album lists each candidate folder once, not once per track."""
1539 provider = _provider()
1540 provider.mass.music.albums.get_library_item_by_prov_id = AsyncMock(return_value=None)
1541 provider.exists = AsyncMock(return_value=True)
1542 track_items = [_item(f"Artist/Album/{n:02d} Track.flac") for n in range(1, 4)]
1543 provider._scandir = AsyncMock(return_value=list(track_items))
1544 good_album = Album(
1545 item_id="Artist/Album", provider=INSTANCE_ID, name="Album", provider_mappings=set()
1546 )
1547
1548 async def _parse_track_side_effect(_item: FileSystemItem, _tags: Any) -> Any:
1549 # exercise the real _ondemand_listing_scope-driven lookup path for each track
1550 nfo_item = await provider._nfo_item_for("Artist", "artist.nfo")
1551 assert nfo_item is None # no artist.nfo in this fixture; only the call count matters
1552 return MagicMock(album=good_album, disc_number=1, track_number=1)
1553
1554 provider._parse_track = AsyncMock(side_effect=_parse_track_side_effect)
1555
1556 with patch(
1557 "music_assistant.providers.filesystem_local.async_parse_tags",
1558 AsyncMock(return_value=MagicMock()),
1559 ):
1560 result = await provider.get_album_tracks("Artist/Album")
1561
1562 assert len(result) == 3
1563 # "Artist" is listed once for the whole scan, not once per track
1564 listed_folders = [call.args[0] for call in provider._scandir.await_args_list]
1565 assert listed_folders.count("Artist") == 1
1566
1567
1568async def test_parse_album_scans_folder_once_when_track_dir_equals_album_dir() -> None:
1569 """When NFO resolution resolves album_dir onto track_dir itself, it is only processed once."""
1570 provider = _provider()
1571 provider.manifest = MagicMock(domain="filesystem_local")
1572 provider.exists = AsyncMock(return_value=True)
1573 nfo_item = _item("Artist/CAT-1234/album.nfo")
1574 provider._scandir = AsyncMock(return_value=[nfo_item])
1575 provider._read_file = AsyncMock(return_value=b"<album><title>My Album</title></album>")
1576 provider._get_local_images = AsyncMock(return_value=[])
1577 provider.cache.get = AsyncMock(return_value=None)
1578 provider.cache.set = AsyncMock()
1579 provider._resolve_artists_with_mbids = AsyncMock(return_value=[])
1580 provider.config.get_value = MagicMock(return_value="various_artists")
1581
1582 tags = MagicMock(
1583 album="My Album",
1584 album_sort=None,
1585 album_artists=[],
1586 barcode=None,
1587 musicbrainz_albumid=None,
1588 musicbrainz_releasegroupid=None,
1589 year=None,
1590 album_type=AlbumType.ALBUM,
1591 filename="track.mp3",
1592 )
1593 await provider._parse_album(track_path="Artist/CAT-1234/t1.mp3", track_tags=tags)
1594
1595 # the resolved album folder ("Artist/CAT-1234") must be scanned for images only once,
1596 # even though it is both the track's own directory and the resolved album directory
1597 album_folder_calls = [
1598 call
1599 for call in provider._get_local_images.await_args_list
1600 if call.args[0] == "Artist/CAT-1234"
1601 ]
1602 assert len(album_folder_calls) == 1
1603
1604
1605async def test_parse_album_never_enriches_from_the_losing_candidate_folders_own_nfo() -> None:
1606 """
1607 Only the validated, winning NFO applies - a rejected candidate's own NFO must not too.
1608
1609 The track's own directory ("Artist/CAT-1234") has its own album.nfo, but it names a
1610 different album and is rejected during resolution; the parent's album.nfo wins instead.
1611 Both folders are still visited by the enrichment loop (`dict.fromkeys((track_dir,
1612 album_dir))`), so the losing folder's unvalidated album.nfo must not leak its own genre
1613 into the resolved album (the winning NFO here sets no genre of its own, so a leaked value
1614 would otherwise survive even though the losing folder is processed before the winner).
1615 """
1616 provider = _provider()
1617 provider.manifest = MagicMock(domain="filesystem_local")
1618 provider.exists = AsyncMock(return_value=True)
1619 provider._get_local_images = AsyncMock(return_value=[])
1620 provider.cache.get = AsyncMock(return_value=None)
1621 provider.cache.set = AsyncMock()
1622 provider._resolve_artists_with_mbids = AsyncMock(return_value=[])
1623 provider.config.get_value = MagicMock(return_value="various_artists")
1624
1625 async def _scandir(folder: str, use_cache: bool = True) -> list[FileSystemItem]: # noqa: ARG001
1626 return [_item(f"{folder}/album.nfo")]
1627
1628 async def _read_file(path: str) -> bytes:
1629 if path == "Artist/CAT-1234/album.nfo":
1630 return b"<album><title>Wrong Album</title><genre>Jazz</genre></album>"
1631 return b"<album><title>My Album</title></album>"
1632
1633 provider._scandir = AsyncMock(side_effect=_scandir)
1634 provider._read_file = AsyncMock(side_effect=_read_file)
1635
1636 tags = MagicMock(
1637 album="My Album",
1638 album_sort=None,
1639 album_artists=[],
1640 barcode=None,
1641 musicbrainz_albumid=None,
1642 musicbrainz_releasegroupid=None,
1643 year=None,
1644 album_type=AlbumType.ALBUM,
1645 filename="track.mp3",
1646 )
1647 album = await provider._parse_album(track_path="Artist/CAT-1234/t1.mp3", track_tags=tags)
1648
1649 assert album.name == "My Album"
1650 assert not album.metadata.genres
1651
1652
1653async def test_parse_album_artist_resolves_from_ancestor_nfo_while_album_stays_synthetic() -> None:
1654 """
1655 The artist's own ancestor resolution must not depend on the album resolving too.
1656
1657 The track's directory ("Artist/CAT-1234") matches neither the album name nor any
1658 album.nfo there, so the album stays synthetic (tag-only, no folder). The artist must
1659 still be looked up starting from that same directory (not only when an album folder was
1660 found), so its ancestor artist.nfo one level up ("Artist") still resolves it to a real
1661 folder.
1662 """
1663 provider = _provider()
1664 provider.manifest = MagicMock(domain="filesystem_local")
1665 # deliberately not the root-level "The Artist" folder itself: forces the resolution to
1666 # rely on the ancestor artist.nfo, not a root-level literal name match
1667 provider.exists = AsyncMock(return_value=False)
1668 provider._get_local_images = AsyncMock(return_value=[])
1669 provider.cache.get = AsyncMock(return_value=None)
1670 provider.cache.set = AsyncMock()
1671 provider._resolve_artists_with_mbids = AsyncMock(
1672 return_value=[("The Artist", ARTIST_MBID, None)]
1673 )
1674
1675 async def _scandir(folder: str, use_cache: bool = True) -> list[FileSystemItem]: # noqa: ARG001
1676 if folder == "Artist":
1677 return [_item("Artist/artist.nfo")]
1678 return [] # no album.nfo anywhere: the album can never resolve to a folder
1679
1680 async def _read_file(path: str) -> bytes:
1681 assert path == "Artist/artist.nfo"
1682 return f"<artist><musicbrainzartistid>{ARTIST_MBID}</musicbrainzartistid></artist>".encode()
1683
1684 provider._scandir = AsyncMock(side_effect=_scandir)
1685 provider._read_file = AsyncMock(side_effect=_read_file)
1686
1687 tags = MagicMock(
1688 album="My Album",
1689 album_sort=None,
1690 album_artists=["The Artist"],
1691 barcode=None,
1692 musicbrainz_albumid=None,
1693 musicbrainz_releasegroupid=None,
1694 year=None,
1695 album_type=AlbumType.ALBUM,
1696 filename="track.mp3",
1697 )
1698 album = await provider._parse_album(track_path="Artist/CAT-1234/t1.mp3", track_tags=tags)
1699
1700 # the album itself never resolved to a folder: a synthetic, name-based identity
1701 assert album.provider_mappings
1702 album_mapping = next(iter(album.provider_mappings))
1703 assert album_mapping.url is None
1704 assert album_mapping.item_id == "The Artist" + os.sep + "My Album"
1705 # the artist resolved to its real ancestor folder regardless
1706 assert album.artists[0].item_id == "Artist"
1707
1708
1709async def test_parse_album_artist_resolves_from_ancestor_name_while_album_stays_synthetic() -> None:
1710 """The same anchoring also applies to a normal (non-NFO) ancestor name match."""
1711 provider = _provider()
1712 provider.manifest = MagicMock(domain="filesystem_local")
1713 # only the true ancestor folder "Music/The Artist" exists; a root-level "The Artist" (or
1714 # its filesystem-safe variant) does not, so a false-positive root-level shortcut can't mask
1715 # whether the ancestor walk itself is actually anchored on the track's own directory
1716 provider.exists = AsyncMock(side_effect=lambda path: path == "Music/The Artist")
1717 provider._get_local_images = AsyncMock(return_value=[])
1718 provider.cache.get = AsyncMock(return_value=None)
1719 provider.cache.set = AsyncMock()
1720 provider._resolve_artists_with_mbids = AsyncMock(return_value=[("The Artist", None, None)])
1721 provider._scandir = AsyncMock(return_value=[]) # no NFOs anywhere
1722
1723 tags = MagicMock(
1724 album="My Album",
1725 album_sort=None,
1726 album_artists=["The Artist"],
1727 barcode=None,
1728 musicbrainz_albumid=None,
1729 musicbrainz_releasegroupid=None,
1730 year=None,
1731 album_type=AlbumType.ALBUM,
1732 filename="track.mp3",
1733 )
1734 album = await provider._parse_album(
1735 track_path="Music/The Artist/CAT-1234/t1.mp3", track_tags=tags
1736 )
1737
1738 assert album.provider_mappings
1739 album_mapping = next(iter(album.provider_mappings))
1740 assert album_mapping.url is None
1741 # the artist resolved by ordinary folder-name matching, anchored on the track's own
1742 # directory rather than a (never-resolved) album directory
1743 assert album.artists[0].item_id == "Music/The Artist"
1744
1745
1746# --- three-tier precedence: exact folder match, then validated NFO, then relaxed match ----
1747
1748
1749async def test_parse_album_exact_folder_match_skips_nfo_resolution() -> None:
1750 """An exact (normalized) folder match wins outright; a validated album.nfo is never tried."""
1751 provider = _provider()
1752 provider.manifest = MagicMock(domain="filesystem_local")
1753 provider.exists = AsyncMock(return_value=True)
1754 provider._scandir = AsyncMock(return_value=[])
1755 provider._get_local_images = AsyncMock(return_value=[])
1756 provider.cache.get = AsyncMock(return_value=None)
1757 provider.cache.set = AsyncMock()
1758 provider._resolve_artists_with_mbids = AsyncMock(return_value=[("The Artist", None, None)])
1759 provider._resolve_album_dir_via_nfo = AsyncMock(
1760 return_value=("Artist", _item("Artist/album.nfo"), {"title": "My Album"})
1761 )
1762
1763 tags = MagicMock(
1764 album="My Album",
1765 album_sort=None,
1766 album_artists=["The Artist"],
1767 barcode=None,
1768 musicbrainz_albumid=None,
1769 musicbrainz_releasegroupid=None,
1770 year=None,
1771 album_type=AlbumType.ALBUM,
1772 filename="track.mp3",
1773 )
1774 album = await provider._parse_album(track_path="Artist/My Album/t1.mp3", track_tags=tags)
1775
1776 album_mapping = next(iter(album.provider_mappings))
1777 assert album_mapping.url == "Artist/My Album"
1778 provider._resolve_album_dir_via_nfo.assert_not_awaited()
1779
1780
1781async def test_parse_album_validated_nfo_outranks_a_relaxed_date_prefix_match() -> None:
1782 """
1783 A validated album.nfo at the parent outranks a relaxed date-prefix match at track_dir.
1784
1785 The track's own directory is date-prefixed ("2025-03-14 My Album") and would match the
1786 album by the new relaxed date-prefix heuristic alone; but its true parent has its own
1787 validated album.nfo, and the bounded NFO fallback is tried before any relaxed heuristic.
1788 """
1789 provider = _provider()
1790 provider.manifest = MagicMock(domain="filesystem_local")
1791 provider.exists = AsyncMock(return_value=True)
1792 provider._get_local_images = AsyncMock(return_value=[])
1793 provider.cache.get = AsyncMock(return_value=None)
1794 provider.cache.set = AsyncMock()
1795 provider._resolve_artists_with_mbids = AsyncMock(return_value=[("The Artist", None, None)])
1796
1797 async def _scandir(folder: str, use_cache: bool = True) -> list[FileSystemItem]: # noqa: ARG001
1798 if folder == "Artist/RealAlbumFolder":
1799 return [_item("Artist/RealAlbumFolder/album.nfo")]
1800 return [] # the date-prefixed track_dir itself has no album.nfo of its own
1801
1802 async def _read_file(path: str) -> bytes:
1803 assert path == "Artist/RealAlbumFolder/album.nfo"
1804 return b"<album><title>My Album</title></album>"
1805
1806 provider._scandir = AsyncMock(side_effect=_scandir)
1807 provider._read_file = AsyncMock(side_effect=_read_file)
1808
1809 tags = MagicMock(
1810 album="My Album",
1811 album_sort=None,
1812 album_artists=["The Artist"],
1813 barcode=None,
1814 musicbrainz_albumid=None,
1815 musicbrainz_releasegroupid=None,
1816 year=None,
1817 album_type=AlbumType.ALBUM,
1818 filename="track.mp3",
1819 )
1820 album = await provider._parse_album(
1821 track_path="Artist/RealAlbumFolder/2025-03-14 My Album/t1.mp3", track_tags=tags
1822 )
1823
1824 album_mapping = next(iter(album.provider_mappings))
1825 # the validated parent NFO won, not the date-prefixed track_dir a relaxed match would pick
1826 assert album_mapping.url == "Artist/RealAlbumFolder"
1827
1828
1829async def test_parse_album_malformed_nfo_falls_through_to_relaxed_date_prefix_match() -> None:
1830 """A malformed/non-matching album.nfo leaves the relaxed heuristic as the last resort."""
1831 provider = _provider()
1832 provider.manifest = MagicMock(domain="filesystem_local")
1833 provider.exists = AsyncMock(return_value=True)
1834 provider._get_local_images = AsyncMock(return_value=[])
1835 provider.cache.get = AsyncMock(return_value=None)
1836 provider.cache.set = AsyncMock()
1837 provider._resolve_artists_with_mbids = AsyncMock(return_value=[("The Artist", None, None)])
1838
1839 async def _scandir(folder: str, use_cache: bool = True) -> list[FileSystemItem]: # noqa: ARG001
1840 if folder == "Artist":
1841 return [_item("Artist/album.nfo")]
1842 return []
1843
1844 async def _read_file(path: str) -> bytes:
1845 assert path == "Artist/album.nfo"
1846 # names a different album entirely: never a positive identity match
1847 return b"<album><title>Somebody Else's Album</title></album>"
1848
1849 provider._scandir = AsyncMock(side_effect=_scandir)
1850 provider._read_file = AsyncMock(side_effect=_read_file)
1851
1852 tags = MagicMock(
1853 album="My Album",
1854 album_sort=None,
1855 album_artists=["The Artist"],
1856 barcode=None,
1857 musicbrainz_albumid=None,
1858 musicbrainz_releasegroupid=None,
1859 year=None,
1860 album_type=AlbumType.ALBUM,
1861 filename="track.mp3",
1862 )
1863 album = await provider._parse_album(
1864 track_path="Artist/2025-03-14 My Album/t1.mp3", track_tags=tags
1865 )
1866
1867 album_mapping = next(iter(album.provider_mappings))
1868 # the mismatching NFO was rejected; the relaxed date-prefix match resolved it instead
1869 assert album_mapping.url == "Artist/2025-03-14 My Album"
1870
1871
1872async def test_parse_album_relaxed_match_never_trusts_a_folder_the_nfo_tier_rejected() -> None:
1873 """
1874 A relaxed match landing on a folder the NFO tier already rejected must not trust it.
1875
1876 The bounded validated album.nfo fallback reads and rejects the track's own directory's
1877 album.nfo (it names a different album entirely). The relaxed date-prefix heuristic then
1878 matches that exact same folder through ordinary (non-NFO) matching - the rejected file
1879 must not be silently re-applied during enrichment just because the folder matched some
1880 other way.
1881 """
1882 provider = _provider()
1883 provider.manifest = MagicMock(domain="filesystem_local")
1884 provider.exists = AsyncMock(return_value=True)
1885 provider._get_local_images = AsyncMock(return_value=[])
1886 provider.cache.get = AsyncMock(return_value=None)
1887 provider.cache.set = AsyncMock()
1888 provider._resolve_artists_with_mbids = AsyncMock(return_value=[("The Artist", None, None)])
1889 _mock_single_file(
1890 provider,
1891 "Artist/2025-03-14 My Album/album.nfo",
1892 b"<album><title>Somebody Else's Album</title></album>",
1893 )
1894
1895 tags = MagicMock(
1896 album="My Album",
1897 album_sort=None,
1898 album_artists=["The Artist"],
1899 barcode=None,
1900 musicbrainz_albumid=None,
1901 musicbrainz_releasegroupid=None,
1902 year=None,
1903 album_type=AlbumType.ALBUM,
1904 filename="track.mp3",
1905 )
1906 album = await provider._parse_album(
1907 track_path="Artist/2025-03-14 My Album/t1.mp3", track_tags=tags
1908 )
1909
1910 # resolved via the relaxed date-prefix match, but never renamed from the rejected NFO
1911 album_mapping = next(iter(album.provider_mappings))
1912 assert album_mapping.url == "Artist/2025-03-14 My Album"
1913 assert album.name == "My Album"
1914