From 13af2ec1b4997871d89a1421b5314ae7c6e9ea51 Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Sat, 1 Apr 2023 12:01:21 +0200 Subject: [PATCH 1/7] Story so afar. --- .../filesystem_smb_mount/__init__.py | 142 ++++++++++++++++++ .../filesystem_smb_mount/manifest.json | 11 ++ 2 files changed, 153 insertions(+) create mode 100644 music_assistant/server/providers/filesystem_smb_mount/__init__.py create mode 100644 music_assistant/server/providers/filesystem_smb_mount/manifest.json diff --git a/music_assistant/server/providers/filesystem_smb_mount/__init__.py b/music_assistant/server/providers/filesystem_smb_mount/__init__.py new file mode 100644 index 000000000..9b25812c2 --- /dev/null +++ b/music_assistant/server/providers/filesystem_smb_mount/__init__.py @@ -0,0 +1,142 @@ +"""SMB filesystem provider for Music Assistant.""" +from __future__ import annotations + +import asyncio +import logging +import os +from typing import TYPE_CHECKING + +from music_assistant.common.helpers.util import get_ip_from_host +from music_assistant.common.models.config_entries import ConfigEntry +from music_assistant.common.models.enums import ConfigEntryType +from music_assistant.common.models.errors import LoginFailed +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME +from music_assistant.server.providers.filesystem_local.base import ( + CONF_ENTRY_MISSING_ALBUM_ARTIST, + FileSystemProviderBase, +) + +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.models import ProviderInstanceType + +CONF_HOST = "host" +CONF_SHARE = "share" +CONF_SUBFOLDER = "subfolder" +CONF_CONN_LIMIT = "connection_limit" + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + # silence logging a bit on smbprotocol + logging.getLogger("smbprotocol").setLevel("WARNING") + logging.getLogger("smbclient").setLevel("INFO") + # check if valid dns name is given + server: str = config.get_value(CONF_HOST) + if not await get_ip_from_host(server): + raise LoginFailed(f"Unable to resolve {server}, make sure the address is resolveable.") + prov = SMBFileSystemProvider(mass, manifest, config) + await prov.handle_setup() + return prov + + +async def get_config_entries( + mass: MusicAssistant, manifest: ProviderManifest # noqa: ARG001 +) -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider.""" + return ( + ConfigEntry( + key=CONF_HOST, + type=ConfigEntryType.STRING, + label="Server", + required=True, + description="The (fqdn) hostname of the SMB/CIFS/DFS server to connect to." + "For example mynas.local.", + ), + ConfigEntry( + key=CONF_SHARE, + type=ConfigEntryType.STRING, + label="Share", + required=True, + description="The name of the share/service you'd like to connect to on " + "the remote host, For example 'media'.", + ), + ConfigEntry( + key=CONF_USERNAME, + type=ConfigEntryType.STRING, + label="Username", + required=True, + default_value="guest", + description="The username to authenticate to the remote server. " + "For anynymous access you may want to try with the user `guest`.", + ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=False, + default_value=None, + description="The username to authenticate to the remote server. " + "For anynymous access you may want to try with the user `guest`.", + ), + ConfigEntry( + key=CONF_SUBFOLDER, + type=ConfigEntryType.STRING, + label="Subfolder", + required=False, + default_value="", + description="[optional] Use if your music is stored in a sublevel of the share. " + "E.g. 'collections' or 'albums/A-K'.", + ), + CONF_ENTRY_MISSING_ALBUM_ARTIST, + ) + + +class SMBFileSystemProvider(FileSystemProviderBase): + """Implementation of an SMB File System Provider.""" + + async def handle_setup(self) -> None: + """Handle async initialization of the provider.""" + self.server: str = self.config.get_value(CONF_HOST) + self.share: str = self.config.get_value(CONF_SHARE) + subfolder: str = self.config.get_value(CONF_SUBFOLDER) + + # create windows like path (\\server\share\subfolder) + if subfolder.endswith(os.sep): + subfolder = subfolder[:-1] + self.subfolder = subfolder.replace("\\", os.sep).replace("/", os.sep) + self._root_path = f"{os.sep}{os.sep}{self.server}{os.sep}{self.share}{os.sep}{subfolder}" + self.logger.debug("Using root path: %s", self._root_path) + + # register smb session + self.logger.info("Connecting to server %s", self.server) + try: + await self.mount_smb_share() + except Exception as err: + raise LoginFailed(f"Connection failed for the given details: {err}") from err + + async def mount_smb_share(self) -> None: + """Mount the SMB share.""" + mount_point = f"/media/mass_smb/{self.instance_id}" + # if not os.path.exists(mount_point): + # os.makedirs(mount_point) + cmd = f"mount -t cifs {self._root_path} -o user={self.config.get_value(CONF_USERNAME)},password={self.config.get_value(CONF_PASSWORD)} {mount_point}" + await run(cmd) + + +async def run(cmd): + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await proc.communicate() + + print(f"[{cmd!r} exited with {proc.returncode}]") + if stdout: + print(f"[stdout]\n{stdout.decode()}") + if stderr: + print(f"[stderr]\n{stderr.decode()}") diff --git a/music_assistant/server/providers/filesystem_smb_mount/manifest.json b/music_assistant/server/providers/filesystem_smb_mount/manifest.json new file mode 100644 index 000000000..b7dc0a9bd --- /dev/null +++ b/music_assistant/server/providers/filesystem_smb_mount/manifest.json @@ -0,0 +1,11 @@ +{ + "type": "music", + "domain": "filesystem_smb_mount", + "name": "Filesystem (remote share mounted)", + "description": "Support for music files that are present on remote SMB/CIFS or DFS share.", + "codeowners": ["@music-assistant"], + "requirements": [], + "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820", + "multi_instance": true, + "icon": "mdi:mdi-network" + } From 4d34ef042aa0c47786a1c255671de708de6c2092 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Apr 2023 14:23:43 +0200 Subject: [PATCH 2/7] add mount commands --- .../providers/filesystem_local/__init__.py | 24 +- .../providers/filesystem_smb/__init__.py | 264 +++++------------- .../providers/filesystem_smb/manifest.json | 20 +- .../filesystem_smb_mount/__init__.py | 142 ---------- .../filesystem_smb_mount/manifest.json | 11 - requirements_all.txt | 1 - 6 files changed, 90 insertions(+), 372 deletions(-) delete mode 100644 music_assistant/server/providers/filesystem_smb_mount/__init__.py delete mode 100644 music_assistant/server/providers/filesystem_smb_mount/manifest.json diff --git a/music_assistant/server/providers/filesystem_local/__init__.py b/music_assistant/server/providers/filesystem_local/__init__.py index 3cb695cac..c3c8d2764 100644 --- a/music_assistant/server/providers/filesystem_local/__init__.py +++ b/music_assistant/server/providers/filesystem_local/__init__.py @@ -34,12 +34,16 @@ isdir = wrap(os.path.isdir) isfile = wrap(os.path.isfile) exists = wrap(os.path.exists) +makedirs = wrap(os.makedirs) async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" + conf_path = config.get_value(CONF_PATH) + if not await isdir(conf_path): + raise SetupFailedError(f"Music Directory {conf_path} does not exist") prov = LocalFileSystemProvider(mass, manifest, config) await prov.handle_setup() return prov @@ -80,11 +84,11 @@ def _create_item(): class LocalFileSystemProvider(FileSystemProviderBase): """Implementation of a musicprovider for local files.""" + base_path: str + async def handle_setup(self) -> None: """Handle async initialization of the provider.""" - conf_path = self.config.get_value(CONF_PATH) - if not await isdir(conf_path): - raise SetupFailedError(f"Music Directory {conf_path} does not exist") + self.base_path = self.config.get_value(CONF_PATH) async def listdir( self, path: str, recursive: bool = False @@ -102,14 +106,14 @@ async def listdir( AsyncGenerator yielding FileSystemItem objects. """ - abs_path = get_absolute_path(self.config.get_value(CONF_PATH), path) + abs_path = get_absolute_path(self.base_path, path) self.logger.debug("Processing: %s", abs_path) entries = await asyncio.to_thread(os.scandir, abs_path) for entry in entries: if entry.name.startswith(".") or any(x in entry.name for x in IGNORE_DIRS): # skip invalid/system files and dirs continue - item = await create_item(self.config.get_value(CONF_PATH), entry) + item = await create_item(self.base_path, entry) if recursive and item.is_dir: try: async for subitem in self.listdir(item.absolute_path, True): @@ -127,13 +131,13 @@ async def resolve( If require_local is True, we prefer to have the `local_path` attribute filled (e.g. with a tempfile), if supported by the provider/item. """ - absolute_path = get_absolute_path(self.config.get_value(CONF_PATH), file_path) + absolute_path = get_absolute_path(self.base_path, file_path) def _create_item(): stat = os.stat(absolute_path, follow_symlinks=False) return FileSystemItem( name=os.path.basename(file_path), - path=get_relative_path(self.config.get_value(CONF_PATH), file_path), + path=get_relative_path(self.base_path, file_path), absolute_path=absolute_path, is_dir=os.path.isdir(absolute_path), is_file=os.path.isfile(absolute_path), @@ -150,12 +154,12 @@ async def exists(self, file_path: str) -> bool: """Return bool is this FileSystem musicprovider has given file/dir.""" if not file_path: return False # guard - abs_path = get_absolute_path(self.config.get_value(CONF_PATH), file_path) + abs_path = get_absolute_path(self.base_path, file_path) return await exists(abs_path) async def read_file_content(self, file_path: str, seek: int = 0) -> AsyncGenerator[bytes, None]: """Yield (binary) contents of file in chunks of bytes.""" - abs_path = get_absolute_path(self.config.get_value(CONF_PATH), file_path) + abs_path = get_absolute_path(self.base_path, file_path) chunk_size = 512000 async with aiofiles.open(abs_path, "rb") as _file: if seek: @@ -169,6 +173,6 @@ async def read_file_content(self, file_path: str, seek: int = 0) -> AsyncGenerat async def write_file_content(self, file_path: str, data: bytes) -> None: """Write entire file content as bytes (e.g. for playlists).""" - abs_path = get_absolute_path(self.config.get_value(CONF_PATH), file_path) + abs_path = get_absolute_path(self.base_path, file_path) async with aiofiles.open(abs_path, "wb") as _file: await _file.write(data) diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index 49297b6a0..e7e5de9ca 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -2,31 +2,19 @@ from __future__ import annotations import asyncio -import logging -import os -from collections.abc import AsyncGenerator -from contextlib import suppress -from os.path import basename +import platform from typing import TYPE_CHECKING -import smbclient -from smbclient import path as smbpath - -from music_assistant.common.helpers.util import empty_queue, get_ip_from_host +from music_assistant.common.helpers.util import get_ip_from_host from music_assistant.common.models.config_entries import ConfigEntry from music_assistant.common.models.enums import ConfigEntryType from music_assistant.common.models.errors import LoginFailed from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME -from music_assistant.server.controllers.cache import use_cache -from music_assistant.server.providers.filesystem_local.base import ( +from music_assistant.server.providers.filesystem_local import ( CONF_ENTRY_MISSING_ALBUM_ARTIST, - IGNORE_DIRS, - FileSystemItem, - FileSystemProviderBase, -) -from music_assistant.server.providers.filesystem_local.helpers import ( - get_absolute_path, - get_relative_path, + LocalFileSystemProvider, + exists, + makedirs, ) if TYPE_CHECKING: @@ -38,20 +26,20 @@ CONF_HOST = "host" CONF_SHARE = "share" CONF_SUBFOLDER = "subfolder" -CONF_CONN_LIMIT = "connection_limit" async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - # silence logging a bit on smbprotocol - logging.getLogger("smbprotocol").setLevel("WARNING") - logging.getLogger("smbclient").setLevel("INFO") - # check if valid dns name is given + # check if valid dns name is given for the host server: str = config.get_value(CONF_HOST) if not await get_ip_from_host(server): raise LoginFailed(f"Unable to resolve {server}, make sure the address is resolveable.") + # check if share is valid + share: str = config.get_value(CONF_SHARE) + if not share or "/" in share or "\\" in share: + raise LoginFailed("Invalid share name") prov = SMBFileSystemProvider(mass, manifest, config) await prov.handle_setup() return prov @@ -109,191 +97,71 @@ async def get_config_entries( ) -async def create_item(base_path: str, entry: smbclient.SMBDirEntry) -> FileSystemItem: - """Create FileSystemItem from smbclient.SMBDirEntry.""" - - def _create_item(): - entry_path = entry.path.replace("/\\", os.sep).replace("\\", os.sep) - absolute_path = get_absolute_path(base_path, entry_path) - stat = entry.stat(follow_symlinks=False) - return FileSystemItem( - name=entry.name, - path=get_relative_path(base_path, entry_path), - absolute_path=absolute_path, - is_file=entry.is_file(follow_symlinks=False), - is_dir=entry.is_dir(follow_symlinks=False), - checksum=str(int(stat.st_mtime)), - file_size=stat.st_size, - ) - - # run in thread because strictly taken this may be blocking IO - return await asyncio.to_thread(_create_item) - +class SMBFileSystemProvider(LocalFileSystemProvider): + """ + Implementation of an SMB File System Provider. -class SMBFileSystemProvider(FileSystemProviderBase): - """Implementation of an SMB File System Provider.""" + Basically this is just a wrapper around the regular local files provider, + except for the fact that it will mount a remote folder to a temporary location. + We went for this OS-depdendent approach because there is no solid async-compatible + smb library for Python (and we tried both pysmb and smbprotocol). + """ async def handle_setup(self) -> None: """Handle async initialization of the provider.""" - server: str = self.config.get_value(CONF_HOST) - share: str = self.config.get_value(CONF_SHARE) - subfolder: str = self.config.get_value(CONF_SUBFOLDER) - - # create windows like path (\\server\share\subfolder) - if subfolder.endswith(os.sep): - subfolder = subfolder[:-1] - subfolder = subfolder.replace("\\", os.sep).replace("/", os.sep) - self._root_path = f"{os.sep}{os.sep}{server}{os.sep}{share}{os.sep}{subfolder}" - self.logger.debug("Using root path: %s", self._root_path) + # base_path will be the path where we're going to mount the remote share + self.base_path = f"/tmp/{self.instance_id}" + if not await exists(self.base_path): + await makedirs(self.base_path) - # register smb session - self.logger.info("Connecting to server %s", server) try: - self._session = await asyncio.to_thread( - smbclient.register_session, - server, - username=self.config.get_value(CONF_USERNAME), - password=self.config.get_value(CONF_PASSWORD), - ) - # validate provided path - if not await asyncio.to_thread(smbpath.isdir, self._root_path): - raise LoginFailed(f"Invalid subfolder given: {subfolder}") + await self.mount() except Exception as err: - if "Unable to negotiate " in str(err): - detail = "Invalid credentials" - elif "refused " in str(err): - detail = "Invalid hostname (or host not reachable)" - elif "STATUS_NOT_FOUND" in str(err): - detail = "Share does not exist" - elif "Invalid argument" in str(err) and "." not in server: - detail = "Make sure to enter a FQDN hostname or IP-address" - else: - detail = str(err) - raise LoginFailed(f"Connection failed for the given details: {detail}") from err - - async def listdir( - self, path: str, recursive: bool = False - ) -> AsyncGenerator[FileSystemItem, None]: - """List contents of a given provider directory/path. - - Parameters - ---------- - - path: path of the directory (relative or absolute) to list contents of. - Empty string for provider's root. - - recursive: If True will recursively keep unwrapping subdirectories (scandir equivalent). - - Returns: - ------- - AsyncGenerator yielding FileSystemItem objects. - - """ - abs_path = get_absolute_path(self._root_path, path) - self.logger.debug("Processing: %s", abs_path) - entries = await asyncio.to_thread(smbclient.scandir, abs_path) - for entry in entries: - if entry.name.startswith(".") or any(x in entry.name for x in IGNORE_DIRS): - # skip invalid/system files and dirs - continue - item = await create_item(self._root_path, entry) - if recursive and item.is_dir: - async for subitem in self.listdir(item.absolute_path, True): - yield subitem - else: - yield item - - async def resolve( - self, file_path: str, require_local: bool = False # noqa: ARG002 - ) -> FileSystemItem: - """Resolve (absolute or relative) path to FileSystemItem. + raise LoginFailed(f"Connection failed for the given details: {err}") from err - If require_local is True, we prefer to have the `local_path` attribute filled - (e.g. with a tempfile), if supported by the provider/item. - """ - file_path = file_path.replace("\\", os.sep) - absolute_path = get_absolute_path(self._root_path, file_path) - - def _create_item(): - stat = smbclient.stat(absolute_path, follow_symlinks=False) - return FileSystemItem( - name=basename(file_path), - path=get_relative_path(self._root_path, file_path), - absolute_path=absolute_path, - is_dir=smbpath.isdir(absolute_path), - is_file=smbpath.isfile(absolute_path), - checksum=str(int(stat.st_mtime)), - file_size=stat.st_size, - ) - - # run in thread because strictly taken this may be blocking IO - return await asyncio.to_thread(_create_item) - - @use_cache(120) - async def exists(self, file_path: str) -> bool: - """Return bool is this FileSystem musicprovider has given file/dir.""" - if not file_path: - return False # guard - file_path = file_path.replace("\\", os.sep) - abs_path = get_absolute_path(self._root_path, file_path) - try: - return await asyncio.to_thread(smbpath.exists, abs_path) - except Exception as err: - if "STATUS_OBJECT_NAME_INVALID" in str(err): - return False - raise err - - async def read_file_content(self, file_path: str, seek: int = 0) -> AsyncGenerator[bytes, None]: - """Yield (binary) contents of file in chunks of bytes.""" - file_path = file_path.replace("\\", os.sep) - absolute_path = get_absolute_path(self._root_path, file_path) - - queue = asyncio.Queue(1) + async def mount(self) -> None: + """Mount the SMB location to a temporary folder.""" + server: str = self.config.get_value(CONF_HOST) + username: str = self.config.get_value(CONF_USERNAME) + password: str = self.config.get_value(CONF_PASSWORD) + share: str = self.config.get_value(CONF_SHARE) + # handle optional subfolder + subfolder: str = self.config.get_value(CONF_SUBFOLDER) + if subfolder: + subfolder = subfolder.replace("\\", "/") + if not subfolder.startswith("/"): + subfolder = "/" + subfolder + if subfolder.endswith("/"): + subfolder = subfolder[:-1] - def _reader(): - self.logger.debug("Reading file contents for %s", absolute_path) - try: - chunk_size = 64000 - bytes_sent = 0 - with smbclient.open_file( - absolute_path, "rb", buffering=chunk_size, share_access="r" - ) as _file: - if seek: - _file.seek(seek) - while True: - chunk = _file.read(chunk_size) - if not chunk: - return - asyncio.run_coroutine_threadsafe(queue.put(chunk), self.mass.loop).result() - bytes_sent += len(chunk) - finally: - asyncio.run_coroutine_threadsafe(queue.put(b""), self.mass.loop).result() - self.logger.debug( - "Finished Reading file contents for %s - bytes transferred: %s", - absolute_path, - bytes_sent, - ) + if platform.system() == "Darwin": + password = f":{password}" if password else "" + mount_cmd = f"mount -t smbfs //{username}{password}@{server}/{share}{subfolder} {self.base_path}" # noqa: E501 - try: - task = self.mass.create_task(_reader) + elif platform.system() == "Linux": + password = f",password={password}" if password else "" + mount_cmd = f"mount -t cifs //{server}/{share}{subfolder} -o user={username}{password} {self.base_path}" # noqa: E501 - while True: - chunk = await queue.get() - if not chunk: - break - yield chunk - finally: - empty_queue(queue) - if task and not task.done(): - task.cancel() - with suppress(asyncio.CancelledError): - await task + else: + raise LoginFailed(f"SMB provider is not supported on {platform.system()}") - async def write_file_content(self, file_path: str, data: bytes) -> None: - """Write entire file content as bytes (e.g. for playlists).""" - file_path = file_path.replace("\\", os.sep) - abs_path = get_absolute_path(self._root_path, file_path) + self.logger.info("Mounting \\\\%s\\%s%s to %s", server, share, subfolder, self.base_path) + self.logger.debug("Using mount command: %s", mount_cmd) - def _writer(): - with smbclient.open_file(abs_path, "wb") as _file: - _file.write(data) - - await asyncio.to_thread(_writer) + proc = await asyncio.create_subprocess_shell( + mount_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + raise LoginFailed("SMB mount failed with error: %s", stderr.decode()) + + async def unmount(self) -> None: + """Unmount the remote share.""" + proc = await asyncio.create_subprocess_shell( + f"umount {self.base_path}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + raise LoginFailed("SMB mount failed with error: %s", stderr.decode()) diff --git a/music_assistant/server/providers/filesystem_smb/manifest.json b/music_assistant/server/providers/filesystem_smb/manifest.json index 29c068281..4566279e4 100644 --- a/music_assistant/server/providers/filesystem_smb/manifest.json +++ b/music_assistant/server/providers/filesystem_smb/manifest.json @@ -1,11 +1,11 @@ { - "type": "music", - "domain": "filesystem_smb", - "name": "Filesystem (remote share)", - "description": "Support for music files that are present on remote SMB/CIFS or DFS share.", - "codeowners": ["@music-assistant"], - "requirements": ["smbprotocol==1.10.1"], - "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820", - "multi_instance": true, - "icon": "mdi:mdi-network" -} + "type": "music", + "domain": "filesystem_smb", + "name": "Filesystem (remote share)", + "description": "Support for music files that are present on remote SMB/CIFS.", + "codeowners": ["@music-assistant"], + "requirements": [], + "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820", + "multi_instance": true, + "icon": "mdi:mdi-network" + } diff --git a/music_assistant/server/providers/filesystem_smb_mount/__init__.py b/music_assistant/server/providers/filesystem_smb_mount/__init__.py deleted file mode 100644 index 9b25812c2..000000000 --- a/music_assistant/server/providers/filesystem_smb_mount/__init__.py +++ /dev/null @@ -1,142 +0,0 @@ -"""SMB filesystem provider for Music Assistant.""" -from __future__ import annotations - -import asyncio -import logging -import os -from typing import TYPE_CHECKING - -from music_assistant.common.helpers.util import get_ip_from_host -from music_assistant.common.models.config_entries import ConfigEntry -from music_assistant.common.models.enums import ConfigEntryType -from music_assistant.common.models.errors import LoginFailed -from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME -from music_assistant.server.providers.filesystem_local.base import ( - CONF_ENTRY_MISSING_ALBUM_ARTIST, - FileSystemProviderBase, -) - -if TYPE_CHECKING: - from music_assistant.common.models.config_entries import ProviderConfig - from music_assistant.common.models.provider import ProviderManifest - from music_assistant.server import MusicAssistant - from music_assistant.server.models import ProviderInstanceType - -CONF_HOST = "host" -CONF_SHARE = "share" -CONF_SUBFOLDER = "subfolder" -CONF_CONN_LIMIT = "connection_limit" - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - # silence logging a bit on smbprotocol - logging.getLogger("smbprotocol").setLevel("WARNING") - logging.getLogger("smbclient").setLevel("INFO") - # check if valid dns name is given - server: str = config.get_value(CONF_HOST) - if not await get_ip_from_host(server): - raise LoginFailed(f"Unable to resolve {server}, make sure the address is resolveable.") - prov = SMBFileSystemProvider(mass, manifest, config) - await prov.handle_setup() - return prov - - -async def get_config_entries( - mass: MusicAssistant, manifest: ProviderManifest # noqa: ARG001 -) -> tuple[ConfigEntry, ...]: - """Return Config entries to setup this provider.""" - return ( - ConfigEntry( - key=CONF_HOST, - type=ConfigEntryType.STRING, - label="Server", - required=True, - description="The (fqdn) hostname of the SMB/CIFS/DFS server to connect to." - "For example mynas.local.", - ), - ConfigEntry( - key=CONF_SHARE, - type=ConfigEntryType.STRING, - label="Share", - required=True, - description="The name of the share/service you'd like to connect to on " - "the remote host, For example 'media'.", - ), - ConfigEntry( - key=CONF_USERNAME, - type=ConfigEntryType.STRING, - label="Username", - required=True, - default_value="guest", - description="The username to authenticate to the remote server. " - "For anynymous access you may want to try with the user `guest`.", - ), - ConfigEntry( - key=CONF_PASSWORD, - type=ConfigEntryType.SECURE_STRING, - label="Password", - required=False, - default_value=None, - description="The username to authenticate to the remote server. " - "For anynymous access you may want to try with the user `guest`.", - ), - ConfigEntry( - key=CONF_SUBFOLDER, - type=ConfigEntryType.STRING, - label="Subfolder", - required=False, - default_value="", - description="[optional] Use if your music is stored in a sublevel of the share. " - "E.g. 'collections' or 'albums/A-K'.", - ), - CONF_ENTRY_MISSING_ALBUM_ARTIST, - ) - - -class SMBFileSystemProvider(FileSystemProviderBase): - """Implementation of an SMB File System Provider.""" - - async def handle_setup(self) -> None: - """Handle async initialization of the provider.""" - self.server: str = self.config.get_value(CONF_HOST) - self.share: str = self.config.get_value(CONF_SHARE) - subfolder: str = self.config.get_value(CONF_SUBFOLDER) - - # create windows like path (\\server\share\subfolder) - if subfolder.endswith(os.sep): - subfolder = subfolder[:-1] - self.subfolder = subfolder.replace("\\", os.sep).replace("/", os.sep) - self._root_path = f"{os.sep}{os.sep}{self.server}{os.sep}{self.share}{os.sep}{subfolder}" - self.logger.debug("Using root path: %s", self._root_path) - - # register smb session - self.logger.info("Connecting to server %s", self.server) - try: - await self.mount_smb_share() - except Exception as err: - raise LoginFailed(f"Connection failed for the given details: {err}") from err - - async def mount_smb_share(self) -> None: - """Mount the SMB share.""" - mount_point = f"/media/mass_smb/{self.instance_id}" - # if not os.path.exists(mount_point): - # os.makedirs(mount_point) - cmd = f"mount -t cifs {self._root_path} -o user={self.config.get_value(CONF_USERNAME)},password={self.config.get_value(CONF_PASSWORD)} {mount_point}" - await run(cmd) - - -async def run(cmd): - proc = await asyncio.create_subprocess_shell( - cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await proc.communicate() - - print(f"[{cmd!r} exited with {proc.returncode}]") - if stdout: - print(f"[stdout]\n{stdout.decode()}") - if stderr: - print(f"[stderr]\n{stderr.decode()}") diff --git a/music_assistant/server/providers/filesystem_smb_mount/manifest.json b/music_assistant/server/providers/filesystem_smb_mount/manifest.json deleted file mode 100644 index b7dc0a9bd..000000000 --- a/music_assistant/server/providers/filesystem_smb_mount/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "type": "music", - "domain": "filesystem_smb_mount", - "name": "Filesystem (remote share mounted)", - "description": "Support for music files that are present on remote SMB/CIFS or DFS share.", - "codeowners": ["@music-assistant"], - "requirements": [], - "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820", - "multi_instance": true, - "icon": "mdi:mdi-network" - } diff --git a/requirements_all.txt b/requirements_all.txt index 552a9c1e7..e76cacbd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -20,7 +20,6 @@ plexapi==4.13.2 PyChromecast==13.0.6 python-slugify==8.0.1 shortuuid==1.0.11 -smbprotocol==1.10.1 soco==0.29.1 unidecode==1.3.6 xmltodict==0.13.0 From b0cf0d339defb0acc2b18f65631185af26f688fd Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Apr 2023 14:40:56 +0200 Subject: [PATCH 3/7] some finetuning --- .../server/providers/filesystem_local/__init__.py | 3 ++- .../server/providers/filesystem_smb/__init__.py | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/music_assistant/server/providers/filesystem_local/__init__.py b/music_assistant/server/providers/filesystem_local/__init__.py index c3c8d2764..e65896e5d 100644 --- a/music_assistant/server/providers/filesystem_local/__init__.py +++ b/music_assistant/server/providers/filesystem_local/__init__.py @@ -107,7 +107,8 @@ async def listdir( """ abs_path = get_absolute_path(self.base_path, path) - self.logger.debug("Processing: %s", abs_path) + rel_path = get_relative_path(self.base_path, path) + self.logger.debug("Processing: %s", rel_path) entries = await asyncio.to_thread(os.scandir, abs_path) for entry in entries: if entry.name.startswith(".") or any(x in entry.name for x in IGNORE_DIRS): diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index e7e5de9ca..8b5e3e790 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -119,6 +119,14 @@ async def handle_setup(self) -> None: except Exception as err: raise LoginFailed(f"Connection failed for the given details: {err}") from err + async def unload(self) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + """ + await self.unmount() + async def mount(self) -> None: """Mount the SMB location to a temporary folder.""" server: str = self.config.get_value(CONF_HOST) @@ -164,4 +172,4 @@ async def unmount(self) -> None: ) _, stderr = await proc.communicate() if proc.returncode != 0: - raise LoginFailed("SMB mount failed with error: %s", stderr.decode()) + raise LoginFailed("SMB unmount failed with error: %s", stderr.decode()) From d98bfe62f19ef4aa86ebb37c549f31ccb7976e28 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Apr 2023 20:28:46 +0200 Subject: [PATCH 4/7] add cifs-utils to docker file --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 085a59ec2..96680d9ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,7 @@ RUN set -x \ libsox-fmt-all \ libsox3 \ sox \ + cifs-utils \ # cleanup && rm -rf /tmp/* \ && rm -rf /var/lib/apt/lists/* From f73b70db6403a7f5a1cbea54203f7c27cbc92c6b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Apr 2023 20:29:10 +0200 Subject: [PATCH 5/7] split options --- music_assistant/server/providers/filesystem_smb/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index 8b5e3e790..3b9a1f8b6 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -147,8 +147,10 @@ async def mount(self) -> None: mount_cmd = f"mount -t smbfs //{username}{password}@{server}/{share}{subfolder} {self.base_path}" # noqa: E501 elif platform.system() == "Linux": - password = f",password={password}" if password else "" - mount_cmd = f"mount -t cifs //{server}/{share}{subfolder} -o user={username}{password} {self.base_path}" # noqa: E501 + options = ["rw", f'username="{username}"', "uid=$(id -u)", "gid=$(id -g)"] + if password: + options.append(f'password="{password}"') + mount_cmd = f"mount -t cifs -o {','.join(options)} //{server}/{share}{subfolder} {self.base_path}" # noqa: E501 else: raise LoginFailed(f"SMB provider is not supported on {platform.system()}") From 18a0955bbb7a106518a730f88061f97173a1acdf Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Apr 2023 22:34:59 +0200 Subject: [PATCH 6/7] add mount options --- docker-compose.example.yml | 5 ++++ .../providers/filesystem_smb/__init__.py | 23 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 1b30a659a..cc1ee1363 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -11,3 +11,8 @@ services: network_mode: host volumes: - ${USERDIR:-$HOME}/docker/music-assistant-server/data:/data/ + # privileged caps needed to mount smb folders within the container + cap_add: + - SYS_ADMIN + - DAC_READ_SEARCH + privileged: true diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index 3b9a1f8b6..cde1bbfbc 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -26,6 +26,7 @@ CONF_HOST = "host" CONF_SHARE = "share" CONF_SUBFOLDER = "subfolder" +CONF_MOUNT_OPTIONS = "mount_options" async def setup( @@ -93,6 +94,16 @@ async def get_config_entries( description="[optional] Use if your music is stored in a sublevel of the share. " "E.g. 'collections' or 'albums/A-K'.", ), + ConfigEntry( + key=CONF_MOUNT_OPTIONS, + type=ConfigEntryType.STRING, + label="Mount options", + required=False, + advanced=True, + default_value="file_mode=0775,dir_mode=0775,uid=0,gid=0", + description="[optional] Any additional mount options you " + "want to pass to the mount command if needed for your particular setup.", + ), CONF_ENTRY_MISSING_ALBUM_ARTIST, ) @@ -133,6 +144,7 @@ async def mount(self) -> None: username: str = self.config.get_value(CONF_USERNAME) password: str = self.config.get_value(CONF_PASSWORD) share: str = self.config.get_value(CONF_SHARE) + # handle optional subfolder subfolder: str = self.config.get_value(CONF_SUBFOLDER) if subfolder: @@ -147,16 +159,21 @@ async def mount(self) -> None: mount_cmd = f"mount -t smbfs //{username}{password}@{server}/{share}{subfolder} {self.base_path}" # noqa: E501 elif platform.system() == "Linux": - options = ["rw", f'username="{username}"', "uid=$(id -u)", "gid=$(id -g)"] + options = [ + "rw", + f'username="{username}"', + ] if password: options.append(f'password="{password}"') + if mount_options := self.config.get_value(CONF_MOUNT_OPTIONS): + options += mount_options.split(",") mount_cmd = f"mount -t cifs -o {','.join(options)} //{server}/{share}{subfolder} {self.base_path}" # noqa: E501 else: raise LoginFailed(f"SMB provider is not supported on {platform.system()}") - self.logger.info("Mounting \\\\%s\\%s%s to %s", server, share, subfolder, self.base_path) - self.logger.debug("Using mount command: %s", mount_cmd) + self.logger.info("Mounting //%s/%s%s to %s", server, share, subfolder, self.base_path) + self.logger.debug("Using mount command: %s", mount_cmd.replace(password, "########")) proc = await asyncio.create_subprocess_shell( mount_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE From 8a2cdcfb342881c3941d332a90a52baedbe09844 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Apr 2023 22:52:10 +0200 Subject: [PATCH 7/7] some adjustments to logging --- .../server/providers/filesystem_smb/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index cde1bbfbc..2498fc475 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -155,8 +155,8 @@ async def mount(self) -> None: subfolder = subfolder[:-1] if platform.system() == "Darwin": - password = f":{password}" if password else "" - mount_cmd = f"mount -t smbfs //{username}{password}@{server}/{share}{subfolder} {self.base_path}" # noqa: E501 + password_str = f":{password}" if password else "" + mount_cmd = f"mount -t smbfs //{username}{password_str}@{server}/{share}{subfolder} {self.base_path}" # noqa: E501 elif platform.system() == "Linux": options = [ @@ -180,7 +180,7 @@ async def mount(self) -> None: ) _, stderr = await proc.communicate() if proc.returncode != 0: - raise LoginFailed("SMB mount failed with error: %s", stderr.decode()) + raise LoginFailed(f"SMB mount failed with error: {stderr.decode()}") async def unmount(self) -> None: """Unmount the remote share.""" @@ -191,4 +191,4 @@ async def unmount(self) -> None: ) _, stderr = await proc.communicate() if proc.returncode != 0: - raise LoginFailed("SMB unmount failed with error: %s", stderr.decode()) + self.logger.warning("SMB unmount failed with error: %s", stderr.decode())