music-assistant-server

13.3 KBPY
__init__.py
13.3 KB358 lines • python
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, ConfigValueType
11from music_assistant_models.enums import ConfigEntryType
12from music_assistant_models.errors import LoginFailed
13
14from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME, VERBOSE_LOG_LEVEL
15from music_assistant.helpers.process import check_output
16from music_assistant.helpers.util import get_ip_from_host
17from music_assistant.providers.filesystem_local import LocalFileSystemProvider, exists, makedirs
18from music_assistant.providers.filesystem_local.constants import (
19    CONF_ENTRY_CONTENT_TYPE,
20    CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
21    CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
22    CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
23    CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
24    CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
25    CONF_ENTRY_LIBRARY_SYNC_TRACKS,
26    CONF_ENTRY_MISSING_ALBUM_ARTIST,
27)
28
29if TYPE_CHECKING:
30    from music_assistant_models.config_entries import ProviderConfig
31    from music_assistant_models.provider import ProviderManifest
32
33    from music_assistant.mass import MusicAssistant
34    from music_assistant.models import ProviderInstanceType
35
36CONF_HOST = "host"
37CONF_SHARE = "share"
38CONF_SUBFOLDER = "subfolder"
39CONF_SMB_VERSION = "smb_version"
40CONF_CACHE_MODE = "cache_mode"
41
42
43async def setup(
44    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
45) -> ProviderInstanceType:
46    """Initialize provider(instance) with given configuration."""
47    # check if valid dns name is given for the host
48    server = str(config.get_value(CONF_HOST))
49    if not await get_ip_from_host(server):
50        msg = f"Unable to resolve {server}, make sure the address is resolveable."
51        raise LoginFailed(msg)
52    # check if share is valid
53    share = str(config.get_value(CONF_SHARE))
54    if not share or "/" in share or "\\" in share:
55        msg = "Invalid share name"
56        raise LoginFailed(msg)
57    # base_path will be the path where we're going to mount the remote share
58    base_path = f"/tmp/{config.instance_id}"  # noqa: S108
59    return SMBFileSystemProvider(mass, manifest, config, base_path)
60
61
62async def get_config_entries(
63    mass: MusicAssistant,
64    instance_id: str | None = None,
65    action: str | None = None,
66    values: dict[str, ConfigValueType] | None = None,
67) -> tuple[ConfigEntry, ...]:
68    """
69    Return Config entries to setup this provider.
70
71    instance_id: id of an existing provider instance (None if new instance setup).
72    action: [optional] action key called from config entries UI.
73    values: the (intermediate) raw values for config entries sent with the action.
74    """
75    # ruff: noqa: ARG001
76    base_entries = (
77        ConfigEntry(
78            key=CONF_HOST,
79            type=ConfigEntryType.STRING,
80            label="Server",
81            required=True,
82            description="The (fqdn) hostname of the SMB/CIFS/DFS server to connect to."
83            "For example mynas.local.",
84        ),
85        ConfigEntry(
86            key=CONF_SHARE,
87            type=ConfigEntryType.STRING,
88            label="Share",
89            required=True,
90            description="The name of the share/service you'd like to connect to on "
91            "the remote host, For example 'media'.",
92        ),
93        ConfigEntry(
94            key=CONF_USERNAME,
95            type=ConfigEntryType.STRING,
96            label="Username",
97            required=False,
98            default_value="guest",
99            description="The username to authenticate to the remote server. "
100            "Leave as 'guest' or empty for anonymous access.",
101        ),
102        ConfigEntry(
103            key=CONF_PASSWORD,
104            type=ConfigEntryType.SECURE_STRING,
105            label="Password",
106            required=False,
107            default_value=None,
108            description="The password to authenticate to the remote server. "
109            "Leave empty for anonymous/guest access.",
110        ),
111        ConfigEntry(
112            key=CONF_SUBFOLDER,
113            type=ConfigEntryType.STRING,
114            label="Subfolder",
115            required=False,
116            default_value="",
117            description="[optional] Use if your music is stored in a sublevel of the share. "
118            "E.g. 'collections' or 'albums/A-K'.",
119        ),
120        ConfigEntry(
121            key=CONF_SMB_VERSION,
122            type=ConfigEntryType.STRING,
123            label="SMB Version",
124            required=False,
125            advanced=True,
126            default_value="3.0",
127            options=[
128                ConfigValueOption("Auto", ""),
129                ConfigValueOption("SMB 1.0", "1.0"),
130                ConfigValueOption("SMB 2.0", "2.0"),
131                ConfigValueOption("SMB 2.1", "2.1"),
132                ConfigValueOption("SMB 3.0", "3.0"),
133                ConfigValueOption("SMB 3.1.1", "3.1.1"),
134            ],
135            description="The SMB protocol version to use. SMB 3.0 or higher is recommended for "
136            "better performance and security. Use Auto to let the system negotiate.",
137        ),
138        ConfigEntry(
139            key=CONF_CACHE_MODE,
140            type=ConfigEntryType.STRING,
141            label="Cache Mode",
142            required=False,
143            advanced=True,
144            default_value="loose",
145            options=[
146                ConfigValueOption("Strict", "strict"),
147                ConfigValueOption("Loose (Recommended)", "loose"),
148                ConfigValueOption("None", "none"),
149            ],
150            description="Cache mode affects performance and consistency. "
151            "'Loose' provides better performance for read-heavy workloads "
152            "and is recommended for music libraries.",
153        ),
154        CONF_ENTRY_MISSING_ALBUM_ARTIST,
155        CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
156        CONF_ENTRY_LIBRARY_SYNC_TRACKS,
157        CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
158        CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
159        CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
160    )
161
162    if instance_id is None or values is None:
163        return (
164            CONF_ENTRY_CONTENT_TYPE,
165            *base_entries,
166        )
167    return (
168        *base_entries,
169        CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
170    )
171
172
173class SMBFileSystemProvider(LocalFileSystemProvider):
174    """
175    Implementation of an SMB File System Provider.
176
177    Basically this is just a wrapper around the regular local files provider,
178    except for the fact that it will mount a remote folder to a temporary location.
179    We went for this OS-depdendent approach because there is no solid async-compatible
180    smb library for Python (and we tried both pysmb and smbprotocol).
181    """
182
183    @property
184    def instance_name_postfix(self) -> str | None:
185        """Return a (default) instance name postfix for this provider instance."""
186        share = str(self.config.get_value(CONF_SHARE))
187        subfolder = str(self.config.get_value(CONF_SUBFOLDER))
188        if subfolder:
189            return subfolder
190        if share:
191            return share
192        return None
193
194    async def handle_async_init(self) -> None:
195        """Handle async initialization of the provider."""
196        if not await exists(self.base_path):
197            await makedirs(self.base_path)
198        try:
199            # do unmount first to cleanup any unexpected state
200            await self.unmount(ignore_error=True)
201            await self.mount()
202        except Exception as err:
203            msg = f"Connection failed for the given details: {err}"
204            raise LoginFailed(msg) from err
205        await self.check_write_access()
206
207    async def unload(self, is_removed: bool = False) -> None:
208        """
209        Handle unload/close of the provider.
210
211        Called when provider is deregistered (e.g. MA exiting or config reloading).
212        """
213        await self.unmount()
214
215    async def mount(self) -> None:
216        """Mount the SMB location to a temporary folder."""
217        server = str(self.config.get_value(CONF_HOST))
218        username = str(self.config.get_value(CONF_USERNAME) or "guest")
219        password = self.config.get_value(CONF_PASSWORD)
220        # Type narrowing: password can be str or None
221        password_str: str | None = str(password) if password is not None else None
222        share = str(self.config.get_value(CONF_SHARE))
223
224        # handle optional subfolder
225        subfolder = str(self.config.get_value(CONF_SUBFOLDER) or "")
226        if subfolder:
227            subfolder = subfolder.replace("\\", "/")
228            if not subfolder.startswith("/"):
229                subfolder = "/" + subfolder
230            subfolder = subfolder.removesuffix("/")
231
232        env_vars = os.environ.copy()
233
234        if platform.system() == "Darwin":
235            mount_cmd = self._build_macos_mount_cmd(
236                server, username, password_str, share, subfolder
237            )
238        elif platform.system() == "Linux":
239            mount_cmd, env_vars = self._build_linux_mount_cmd(
240                server, username, password_str, share, subfolder, env_vars
241            )
242        else:
243            msg = f"SMB provider is not supported on {platform.system()}"
244            raise LoginFailed(msg)
245
246        self.logger.debug("Mounting //%s/%s%s to %s", server, share, subfolder, self.base_path)
247        self.logger.log(VERBOSE_LOG_LEVEL, "Using mount command: %s", " ".join(mount_cmd))
248        returncode, output = await check_output(*mount_cmd, env=env_vars)
249        if returncode != 0:
250            msg = f"SMB mount failed with error: {output.decode()}"
251            raise LoginFailed(msg)
252
253    def _build_macos_mount_cmd(
254        self, server: str, username: str, password: str | None, share: str, subfolder: str
255    ) -> list[str]:
256        """Build mount command for macOS."""
257        mount_options = []
258
259        # Add SMB version if specified
260        smb_version = str(self.config.get_value(CONF_SMB_VERSION) or "")
261        if smb_version:
262            # macOS uses different version format (e.g., smb2, smb3)
263            if smb_version.startswith("3"):
264                mount_options.extend(["-o", "protocol_vers_map=6"])  # SMB3
265            elif smb_version.startswith("2"):
266                mount_options.extend(["-o", "protocol_vers_map=4"])  # SMB2
267
268        # Construct credentials in URL format
269        # macOS mount_smbfs supports special characters in password when URL-encoded
270        encoded_password = f":{quote(str(password), safe='')}" if password else ""
271
272        return [
273            "mount",
274            "-t",
275            "smbfs",
276            *mount_options,
277            f"//{username}{encoded_password}@{server}/{share}{subfolder}",
278            self.base_path,
279        ]
280
281    def _build_linux_mount_cmd(
282        self,
283        server: str,
284        username: str,
285        password: str | None,
286        share: str,
287        subfolder: str,
288        env_vars: dict[str, str],
289    ) -> tuple[list[str], dict[str, str]]:
290        """Build mount command for Linux.
291
292        Uses the PASSWD environment variable to handle passwords with special characters
293        (commas, etc.) that cannot be escaped on the command line.
294
295        :param server: The SMB server hostname or IP.
296        :param username: The username for authentication.
297        :param password: The password for authentication (can contain special chars).
298        :param share: The share name on the server.
299        :param subfolder: Optional subfolder path within the share.
300        :param env_vars: Environment variables dict to modify with PASSWD if needed.
301        :returns: Tuple of (mount command args, modified env vars).
302        """
303        options = ["rw"]  # read-write access
304
305        # We pass the password via the PASSWD environment variable to avoid
306        # improperly escaped passwords with special characters.
307        if username and username.lower() != "guest":
308            options.append(f"username={username}")
309            if password:
310                env_vars["PASSWD"] = password
311        else:
312            # Guest/anonymous access
313            options.append("guest")
314
315        # SMB version for better compatibility and performance
316        smb_version = str(self.config.get_value(CONF_SMB_VERSION) or "")
317        if smb_version:
318            options.append(f"vers={smb_version}")
319
320        # Cache mode for better performance
321        cache_mode = str(self.config.get_value(CONF_CACHE_MODE) or "loose")
322        options.append(f"cache={cache_mode}")
323
324        # Case insensitive by default (standard for SMB) and other performance options
325        # Note: iocharset is omitted to allow CIFS native Unicode handling for emoji
326        # and other 4-byte UTF-8 characters.
327        options.extend(
328            [
329                "nocase",
330                "file_mode=0755",
331                "dir_mode=0755",
332                "uid=0",
333                "gid=0",
334                "noperm",
335                "nobrl",
336                "mfsymlinks",
337                "noserverino",
338                "actimeo=30",
339            ]
340        )
341
342        mount_cmd = [
343            "mount",
344            "-t",
345            "cifs",
346            "-o",
347            ",".join(options),
348            f"//{server}/{share}{subfolder}",
349            self.base_path,
350        ]
351        return mount_cmd, env_vars
352
353    async def unmount(self, ignore_error: bool = False) -> None:
354        """Unmount the remote share."""
355        returncode, output = await check_output("umount", self.base_path)
356        if returncode != 0 and not ignore_error:
357            self.logger.warning("SMB unmount failed with error: %s", output.decode())
358