Skip to content

Commit f7f1ac1

Browse files
committed
Skip stale artist paths during filesystem track parsing
When an artist folder on disk is renamed (e.g. from "Nina Simone" to sort-name style "Simone, Nina"), the library keeps the old path in the artist's provider mapping url. _parse_artist trusted that stored path without verifying it exists, so the artwork scan raised FileNotFoundError and aborted sync processing for every track by that artist: Error processing Simone, Nina/.../track.mp3 - [Errno 2] No such file or directory: '/my-music/Nina Simone' Verify the resolved artist path exists before hunting for folder metadata, degrading to "no folder metadata" instead of failing the track.
1 parent 35af34d commit f7f1ac1

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

music_assistant/providers/filesystem_local/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,10 @@ async def _parse_artist(
13041304
artist.mbid = mbid
13051305
if not artist_path:
13061306
return artist
1307+
if not await self.exists(artist_path):
1308+
# the stored artist path may be stale, e.g. when the folder
1309+
# on disk was renamed (such as to "Lastname, Firstname" style)
1310+
return artist
13071311

13081312
# grab additional metadata within the Artist's folder
13091313
nfo_file = os.path.join(artist_path, "artist.nfo")
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Tests for filesystem provider artist path resolution with stale library paths."""
2+
3+
import os
4+
from collections.abc import AsyncGenerator
5+
from pathlib import Path
6+
from unittest.mock import AsyncMock, MagicMock
7+
8+
import pytest
9+
from music_assistant_models.media_items import Artist, ProviderMapping
10+
11+
from music_assistant.helpers.tags import AudioTags
12+
from music_assistant.providers.filesystem_local import LocalFileSystemProvider
13+
14+
INSTANCE_ID = "filesystem_local--test"
15+
16+
ARTIST_FOLDER = "Simone, Nina"
17+
ALBUM_FOLDER = "1987 Live At Ronnie Scott's"
18+
TRACK_FILE = "12. My Baby Just Cares for Me.mp3"
19+
20+
21+
def _make_tags(artist: str, album: str) -> AudioTags:
22+
return AudioTags(
23+
raw={},
24+
sample_rate=44100,
25+
channels=2,
26+
bits_per_sample=16,
27+
format="mp3",
28+
bit_rate=320,
29+
duration=240.0,
30+
tags={
31+
"artist": artist,
32+
"albumartist": artist,
33+
"album": album,
34+
"title": "My Baby Just Cares for Me",
35+
"track": "12",
36+
},
37+
has_cover_image=False,
38+
filename=os.path.join(ARTIST_FOLDER, ALBUM_FOLDER, TRACK_FILE),
39+
)
40+
41+
42+
def _make_provider(base_path: str, lib_artists: list[Artist]) -> LocalFileSystemProvider:
43+
provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
44+
provider.base_path = base_path
45+
provider.logger = MagicMock()
46+
provider.write_access = False
47+
provider.media_content_type = "music"
48+
provider.config = MagicMock()
49+
provider.config.instance_id = INSTANCE_ID
50+
provider.config.get_value = MagicMock(return_value="various_artists")
51+
provider.manifest = MagicMock()
52+
provider.manifest.domain = "filesystem_local"
53+
provider.cache = MagicMock()
54+
provider.cache.get = AsyncMock(return_value=None)
55+
provider.cache.set = AsyncMock(return_value=None)
56+
57+
async def iter_library_items(
58+
search: str | None = None, # noqa: ARG001
59+
provider: str | None = None, # noqa: ARG001
60+
) -> AsyncGenerator[Artist]:
61+
for lib_artist in lib_artists:
62+
yield lib_artist
63+
64+
provider.mass = MagicMock()
65+
provider.mass.music.artists.iter_library_items = iter_library_items
66+
provider.mass.get_provider = MagicMock(return_value=None)
67+
provider.mass.create_task = MagicMock()
68+
return provider
69+
70+
71+
def _lib_artist(name: str, url: str | None) -> Artist:
72+
return Artist(
73+
item_id="1",
74+
provider="library",
75+
name=name,
76+
provider_mappings={
77+
ProviderMapping(
78+
item_id=url or name,
79+
provider_domain="filesystem_local",
80+
provider_instance=INSTANCE_ID,
81+
url=url,
82+
in_library=True,
83+
)
84+
},
85+
)
86+
87+
88+
@pytest.fixture
89+
def music_tree(tmp_path: Path) -> str:
90+
"""Create a sort-name style artist folder with one track file."""
91+
track_dir = tmp_path / ARTIST_FOLDER / ALBUM_FOLDER
92+
track_dir.mkdir(parents=True)
93+
(track_dir / TRACK_FILE).write_bytes(b"\x00" * 128)
94+
return str(tmp_path)
95+
96+
97+
async def test_stale_artist_path_does_not_fail_track_parse(music_tree: str) -> None:
98+
"""A stale artist path stored in the library must not fail parsing the track.
99+
100+
Regression test: the artist folder was renamed from display-name style
101+
("Nina Simone") to sort-name style ("Simone, Nina"), but the library still
102+
holds the old path in the provider mapping url. Parsing any track by that
103+
artist raised FileNotFoundError and aborted the sync for that track.
104+
"""
105+
provider = _make_provider(music_tree, [_lib_artist("Nina Simone", "Nina Simone")])
106+
file_item = await provider.resolve(os.path.join(ARTIST_FOLDER, ALBUM_FOLDER, TRACK_FILE))
107+
108+
track = await provider._parse_track(file_item, _make_tags("Nina Simone", ALBUM_FOLDER))
109+
110+
assert [a.name for a in track.artists] == ["Nina Simone"]
111+
112+
113+
async def test_valid_artist_path_still_resolved(music_tree: str) -> None:
114+
"""A valid stored artist path keeps being used as the artist's item_id."""
115+
provider = _make_provider(music_tree, [_lib_artist("Nina Simone", ARTIST_FOLDER)])
116+
file_item = await provider.resolve(os.path.join(ARTIST_FOLDER, ALBUM_FOLDER, TRACK_FILE))
117+
118+
track = await provider._parse_track(file_item, _make_tags("Nina Simone", ALBUM_FOLDER))
119+
120+
assert [a.item_id for a in track.artists] == [ARTIST_FOLDER]

0 commit comments

Comments
 (0)