/
/
/
1"""Tests for LocalFileSystemProvider._parse_playlist_line path resolution."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from music_assistant_models.enums import MediaType
10
11from music_assistant.helpers.uri import create_uri
12from music_assistant.providers.filesystem_local import LocalFileSystemProvider
13
14if TYPE_CHECKING:
15 from pathlib import Path
16
17
18@pytest.fixture
19def music_dir(tmp_path: Path) -> Path:
20 """Create a music folder with a playlist next to an artist folder."""
21 (tmp_path / "Playlists").mkdir()
22 (tmp_path / "Playlists" / "list.m3u").write_text("../Artist/track.mp3\n")
23 (tmp_path / "Artist").mkdir()
24 (tmp_path / "Artist" / "track.mp3").write_bytes(b"audio-bytes")
25 return tmp_path
26
27
28@pytest.fixture
29def provider(
30 music_dir: Path, monkeypatch: pytest.MonkeyPatch
31) -> tuple[LocalFileSystemProvider, MagicMock]:
32 """Create a provider on the music folder with tag parsing and track building stubbed."""
33 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
34 provider.base_path = str(music_dir)
35 provider.logger = MagicMock()
36 provider.config = MagicMock(instance_id="filesystem_local--test")
37 provider.mass = MagicMock()
38 provider.mass.music.tracks.get_library_item_by_prov_id = AsyncMock(return_value=None)
39 monkeypatch.setattr(
40 "music_assistant.providers.filesystem_local.async_parse_tags",
41 AsyncMock(return_value=MagicMock()),
42 )
43 track = MagicMock(name="track")
44 provider._parse_track = AsyncMock(return_value=track) # type: ignore[method-assign]
45 return provider, track
46
47
48async def test_line_relative_to_playlist_folder(
49 provider: tuple[LocalFileSystemProvider, MagicMock],
50) -> None:
51 """A ../ line resolves against the playlist's own folder."""
52 fs_provider, track = provider
53
54 result = await fs_provider._parse_playlist_line("../Artist/track.mp3", "Playlists")
55
56 assert result is track
57 file_item = fs_provider._parse_track.await_args.args[0] # type: ignore[attr-defined]
58 assert file_item.relative_path == "Artist/track.mp3"
59
60
61async def test_line_relative_to_base_path(
62 provider: tuple[LocalFileSystemProvider, MagicMock],
63) -> None:
64 """A base-path-relative line resolves after the playlist-folder candidate misses."""
65 fs_provider, track = provider
66
67 assert await fs_provider._parse_playlist_line("Artist/track.mp3", "Playlists") is track
68
69
70async def test_absolute_line(
71 provider: tuple[LocalFileSystemProvider, MagicMock], music_dir: Path
72) -> None:
73 """An absolute path in the playlist still resolves."""
74 fs_provider, track = provider
75 absolute_line = str(music_dir / "Artist" / "track.mp3")
76
77 assert await fs_provider._parse_playlist_line(absolute_line, "Playlists") is track
78
79
80async def test_file_uri_line(
81 provider: tuple[LocalFileSystemProvider, MagicMock], music_dir: Path
82) -> None:
83 """A file:// uri with url-encoded characters resolves."""
84 fs_provider, track = provider
85 line = f"file://{music_dir}/Artist/track%2Emp3"
86
87 assert await fs_provider._parse_playlist_line(line, "Playlists") is track
88
89
90async def test_missing_file_returns_none(
91 provider: tuple[LocalFileSystemProvider, MagicMock],
92) -> None:
93 """A line pointing at a nonexistent file logs a warning instead of raising."""
94 fs_provider, _ = provider
95
96 assert await fs_provider._parse_playlist_line("Missing/nope.mp3", "Playlists") is None
97 fs_provider.logger.warning.assert_called_once() # type: ignore[attr-defined]
98
99
100def _library_track_mock(available: bool) -> MagicMock:
101 """Return a library track mock with a provider mapping for Artist/track.mp3."""
102 library_track = MagicMock(name="library_track")
103 library_track.provider_mappings = {
104 MagicMock(
105 provider_instance="filesystem_local--test",
106 item_id="Artist/track.mp3",
107 available=available,
108 )
109 }
110 return library_track
111
112
113async def test_library_track_preferred_over_tag_parsing(
114 provider: tuple[LocalFileSystemProvider, MagicMock],
115) -> None:
116 """A line whose file is already in the library resolves from the db, without tag parsing."""
117 fs_provider, _ = provider
118 library_track = _library_track_mock(available=True)
119 db_lookup = AsyncMock(return_value=library_track)
120 fs_provider.mass.music.tracks.get_library_item_by_prov_id = db_lookup # type: ignore[method-assign]
121
122 result = await fs_provider._parse_playlist_line("../Artist/track.mp3", "Playlists")
123
124 assert result is library_track
125 db_lookup.assert_awaited_once_with("Artist/track.mp3", "filesystem_local--test")
126 # the library track is presented under its provider item identity
127 assert result.item_id == "Artist/track.mp3"
128 assert result.provider == "filesystem_local--test"
129 assert result.uri == create_uri(MediaType.TRACK, "filesystem_local--test", "Artist/track.mp3")
130 fs_provider._parse_track.assert_not_awaited() # type: ignore[attr-defined]
131
132
133async def test_stale_unavailable_library_track_falls_back_to_tag_parsing(
134 provider: tuple[LocalFileSystemProvider, MagicMock],
135) -> None:
136 """A library track whose mapping is (stale) unavailable is re-parsed from the file tags."""
137 fs_provider, track = provider
138 library_track = _library_track_mock(available=False)
139 fs_provider.mass.music.tracks.get_library_item_by_prov_id = AsyncMock( # type: ignore[method-assign]
140 return_value=library_track
141 )
142
143 assert await fs_provider._parse_playlist_line("../Artist/track.mp3", "Playlists") is track
144 fs_provider._parse_track.assert_awaited_once() # type: ignore[attr-defined]
145
146
147async def test_tag_parsing_fallback_when_not_in_library(
148 provider: tuple[LocalFileSystemProvider, MagicMock],
149) -> None:
150 """A line whose file is not (yet) in the library falls back to parsing the file tags."""
151 fs_provider, track = provider
152
153 assert await fs_provider._parse_playlist_line("../Artist/track.mp3", "Playlists") is track
154 fs_provider.mass.music.tracks.get_library_item_by_prov_id.assert_awaited_once() # type: ignore[attr-defined]
155 fs_provider._parse_track.assert_awaited_once() # type: ignore[attr-defined]
156