/
/
/
1"""SMB filesystem provider for Music Assistant."""
2
3from __future__ import annotations
4
5import os
6import platform
7from typing import TYPE_CHECKING
8from urllib.parse import quote
9
10from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
11from music_assistant_models.enums import ConfigEntryType
12from music_assistant_models.errors import LoginFailed, SetupFailedError, UnsupportedSystemError
13
14from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME, VERBOSE_LOG_LEVEL
15from music_assistant.helpers.json import SerializableType
16from music_assistant.helpers.mount import error_summary, unmount
17from music_assistant.helpers.process import check_output
18from music_assistant.helpers.util import get_ip_from_host
19from music_assistant.providers.filesystem_local import (
20 LocalFileSystemProvider,
21 ismount,
22 makedirs,
23)
24from music_assistant.providers.filesystem_local.constants import (
25 CONF_CONTENT_TYPE,
26 CONF_ENTRY_CONTENT_TYPE,
27 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
28 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
29 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
30 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
31 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
32 CONF_ENTRY_MISSING_ALBUM_ARTIST,
33 CONF_ENTRY_PROPAGATE_GENRES,
34 content_type_config_entry,
35)
36
37if TYPE_CHECKING:
38 from music_assistant_models.config_entries import ProviderConfig
39 from music_assistant_models.provider import ProviderManifest
40
41 from music_assistant.mass import MusicAssistant
42 from music_assistant.models import ProviderInstanceType
43
44CONF_HOST = "host"
45CONF_SHARE = "share"
46CONF_SUBFOLDER = "subfolder"
47CONF_SMB_VERSION = "smb_version"
48CONF_CACHE_MODE = "cache_mode"
49
50# lowercase fragments that both mount tools (Linux mount.cifs and macOS mount_smbfs) emit when
51# the server rejected the credentials - only those must be reported back as an auth problem
52_AUTH_FAILURE_MARKERS = (
53 "permission denied",
54 "authentication error",
55 "nt_status_logon_failure",
56 "nt_status_access_denied",
57 "nt_status_account_disabled",
58 "nt_status_account_locked_out",
59 "nt_status_password_expired",
60 "nt_status_wrong_password",
61)
62
63
64async def setup(
65 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
66) -> ProviderInstanceType:
67 """Initialize provider(instance) with given configuration."""
68 # base_path will be the path where we're going to mount the remote share
69 base_path = f"/tmp/{config.instance_id}" # noqa: S108
70 return SMBFileSystemProvider(mass, manifest, config, base_path)
71
72
73class SMBFileSystemProvider(LocalFileSystemProvider):
74 """
75 Implementation of an SMB File System Provider.
76
77 Basically this is just a wrapper around the regular local files provider,
78 except for the fact that it will mount a remote folder to a temporary location.
79 We went for this OS-depdendent approach because there is no solid async-compatible
80 smb library for Python (and we tried both pysmb and smbprotocol).
81 """
82
83 @property
84 def instance_name_postfix(self) -> str | None:
85 """Return a (default) instance name postfix for this provider instance."""
86 share = str(self.get_setup_value(CONF_SHARE))
87 subfolder = str(self.get_setup_value(CONF_SUBFOLDER))
88 if subfolder:
89 return subfolder
90 if share:
91 return share
92 return None
93
94 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
95 """Return Config entries to configure this provider."""
96 # connection details and content type are collected by the setup flow; surface the
97 # (immutable) content type read-only so the sync options' depends_on chains resolve
98 content_type = str(
99 self.get_setup_value(CONF_CONTENT_TYPE, CONF_ENTRY_CONTENT_TYPE.default_value)
100 )
101 return (
102 content_type_config_entry(content_type),
103 ConfigEntry(
104 key=CONF_CACHE_MODE,
105 type=ConfigEntryType.STRING,
106 required=False,
107 advanced=True,
108 default_value="loose",
109 options=[
110 ConfigValueOption("strict"),
111 ConfigValueOption("loose"),
112 ConfigValueOption("none"),
113 ],
114 ),
115 CONF_ENTRY_MISSING_ALBUM_ARTIST,
116 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
117 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
118 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
119 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
120 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
121 CONF_ENTRY_PROPAGATE_GENRES,
122 )
123
124 async def handle_async_init(self) -> None:
125 """Handle async initialization of the provider."""
126 # validate the connection details before attempting to mount
127 server = str(self.get_setup_value(CONF_HOST))
128 if not await get_ip_from_host(server):
129 msg = f"Unable to resolve {server}, make sure the address is resolvable."
130 raise SetupFailedError(
131 msg,
132 translation_key="host_unresolvable",
133 translation_args=[server],
134 )
135 share = str(self.get_setup_value(CONF_SHARE))
136 if not share or "/" in share or "\\" in share:
137 msg = "Invalid share name"
138 raise SetupFailedError(msg)
139 # the mount point may already exist; checking first is not reliable because
140 # reading the path fails while the server is unreachable
141 await makedirs(self.base_path, exist_ok=True)
142 try:
143 # do unmount first to cleanup any unexpected state
144 await unmount(self.base_path, self.logger)
145 await self.mount()
146 except OSError as err:
147 msg = f"Unable to run the mount command: {err}"
148 raise SetupFailedError(msg) from err
149 await self.check_write_access()
150
151 async def unload(self, is_removed: bool = False) -> None:
152 """
153 Handle unload/close of the provider.
154
155 Called when provider is deregistered (e.g. MA exiting or config reloading).
156 """
157 await unmount(self.base_path, self.logger)
158
159 async def get_diagnostics(self) -> dict[str, SerializableType]:
160 """Return diagnostics info for this provider to include in diagnostics reports."""
161 return {
162 **await super().get_diagnostics(),
163 "mounted": await ismount(self.base_path),
164 }
165
166 async def mount(self) -> None:
167 """Mount the SMB location to a temporary folder."""
168 server = str(self.get_setup_value(CONF_HOST))
169 username = str(self.get_setup_value(CONF_USERNAME) or "guest")
170 password = self.get_setup_value(CONF_PASSWORD)
171 # Type narrowing: password can be str or None
172 password_str: str | None = str(password) if password is not None else None
173 share = str(self.get_setup_value(CONF_SHARE))
174
175 # handle optional subfolder
176 subfolder = str(self.get_setup_value(CONF_SUBFOLDER) or "")
177 if subfolder:
178 subfolder = subfolder.replace("\\", "/")
179 if not subfolder.startswith("/"):
180 subfolder = "/" + subfolder
181 subfolder = subfolder.removesuffix("/")
182
183 env_vars = os.environ.copy()
184
185 if platform.system() == "Darwin":
186 mount_cmd = self._build_macos_mount_cmd(
187 server, username, password_str, share, subfolder
188 )
189 elif platform.system() == "Linux":
190 mount_cmd, env_vars = self._build_linux_mount_cmd(
191 server, username, password_str, share, subfolder, env_vars
192 )
193 else:
194 msg = f"SMB provider is not supported on {platform.system()}"
195 raise UnsupportedSystemError(msg)
196
197 self.logger.debug("Mounting //%s/%s%s to %s", server, share, subfolder, self.base_path)
198 self.logger.log(VERBOSE_LOG_LEVEL, "Using mount command: %s", " ".join(mount_cmd))
199 returncode, output = await check_output(*mount_cmd, env=env_vars)
200 if returncode != 0:
201 raise _mount_error(output.decode().strip())
202
203 def _build_macos_mount_cmd(
204 self, server: str, username: str, password: str | None, share: str, subfolder: str
205 ) -> list[str]:
206 """Build mount command for macOS."""
207 mount_options = []
208
209 # Add SMB version if specified
210 smb_version = str(self.get_setup_value(CONF_SMB_VERSION) or "")
211 if smb_version:
212 # macOS uses different version format (e.g., smb2, smb3)
213 if smb_version.startswith("3"):
214 mount_options.extend(["-o", "protocol_vers_map=6"]) # SMB3
215 elif smb_version.startswith("2"):
216 mount_options.extend(["-o", "protocol_vers_map=4"]) # SMB2
217
218 # Construct credentials in URL format
219 # macOS mount_smbfs supports special characters in password when URL-encoded
220 encoded_password = f":{quote(str(password), safe='')}" if password else ""
221
222 return [
223 "mount",
224 "-t",
225 "smbfs",
226 *mount_options,
227 f"//{username}{encoded_password}@{server}/{share}{subfolder}",
228 self.base_path,
229 ]
230
231 def _build_linux_mount_cmd(
232 self,
233 server: str,
234 username: str,
235 password: str | None,
236 share: str,
237 subfolder: str,
238 env_vars: dict[str, str],
239 ) -> tuple[list[str], dict[str, str]]:
240 """
241 Build mount command for Linux.
242
243 Uses the PASSWD environment variable to handle passwords with special characters
244 (commas, etc.) that cannot be escaped on the command line.
245
246 :param server: The SMB server hostname or IP.
247 :param username: The username for authentication.
248 :param password: The password for authentication (can contain special chars).
249 :param share: The share name on the server.
250 :param subfolder: Optional subfolder path within the share.
251 :param env_vars: Environment variables dict to modify with PASSWD if needed.
252 :returns: Tuple of (mount command args, modified env vars).
253 """
254 options = ["rw"] # read-write access
255
256 # We pass the password via the PASSWD environment variable to avoid
257 # improperly escaped passwords with special characters.
258 if username and username.lower() != "guest":
259 options.append(f"username={username}")
260 if password:
261 env_vars["PASSWD"] = password
262 else:
263 # Guest/anonymous access
264 options.append("guest")
265
266 # SMB version for better compatibility and performance
267 smb_version = str(self.get_setup_value(CONF_SMB_VERSION) or "")
268 if smb_version:
269 options.append(f"vers={smb_version}")
270
271 # Cache mode for better performance
272 cache_mode = str(self.config.get_value(CONF_CACHE_MODE) or "loose")
273 options.append(f"cache={cache_mode}")
274
275 # Case insensitive by default (standard for SMB) and other performance options.
276 # Note: emoji and other 4-byte UTF-8 characters (U+10000+) in folder/file names
277 # are NOT supported due to a Linux kernel limitation in the CIFS client's NLS layer.
278 # Items with such characters will be skipped during library sync.
279 options.extend(
280 [
281 "iocharset=utf8",
282 "nocase",
283 "file_mode=0755",
284 "dir_mode=0755",
285 "uid=0",
286 "gid=0",
287 "noperm",
288 "nobrl",
289 "mfsymlinks",
290 "noserverino",
291 "actimeo=30",
292 ]
293 )
294
295 mount_cmd = [
296 "mount",
297 "-t",
298 "cifs",
299 "-o",
300 ",".join(options),
301 f"//{server}/{share}{subfolder}",
302 self.base_path,
303 ]
304 return mount_cmd, env_vars
305
306
307def _mount_error(output: str) -> SetupFailedError | LoginFailed:
308 """
309 Return the error to raise for a failed mount command.
310
311 :param output: The (combined) output of the mount command.
312 """
313 lowered = output.lower()
314 if any(marker in lowered for marker in _AUTH_FAILURE_MARKERS):
315 return LoginFailed(f"SMB mount failed with error: {output}")
316 return SetupFailedError(
317 f"SMB mount failed with error: {output}",
318 translation_key="mount_failed",
319 translation_args=[error_summary(output)],
320 )
321