Fix Fileprovider remote shares support (#574)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 25 Mar 2023 23:26:02 +0000 (00:26 +0100)
committerGitHub <noreply@github.com>
Sat, 25 Mar 2023 23:26:02 +0000 (00:26 +0100)
* bump schema version

* Replace PySMB with smbprotocol library

* fix index error in queue

* Fix multi instance playback issues

* fix shutdown

* ignore recycle bin folders

17 files changed:
Dockerfile
music_assistant/constants.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/providers/filesystem_local/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/filesystem_local/manifest.json
music_assistant/server/providers/filesystem_smb/__init__.py
music_assistant/server/providers/filesystem_smb/helpers.py [deleted file]
music_assistant/server/providers/filesystem_smb/manifest.json
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/url/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/server.py
requirements_all.txt

index 085a59ec287fcd5c38585b3082c94df2cddd538b..8ceb533f535a829336ba6ffb88678eae7cfd18d4 100644 (file)
@@ -17,6 +17,9 @@ RUN set -x \
     && apt-get install -y --no-install-recommends \
         build-essential \
         libffi-dev \
+        gcc \
+        krb5-devel \
+        krb5-workstation \
         cargo \
         git
 
index 233de7860c6039998ef6f8ecf915dfa8d9655374..7eab6e1781c31c3100217cad4da5b7546c00b657 100755 (executable)
@@ -5,7 +5,7 @@ from typing import Final
 
 __version__: Final[str] = "2.0.0b15"
 
-SCHEMA_VERSION: Final[int] = 20
+SCHEMA_VERSION: Final[int] = 21
 
 ROOT_LOGGER_NAME: Final[str] = "music_assistant"
 
index 8bfabd0a81935cffbcdf62a4badc89ec6eccf538..4fcb11b0fd777a3ac107c2ada8ef0bc26ef45a94 100755 (executable)
@@ -138,6 +138,7 @@ class PlayerQueuesController:
         - queue_opt: Which enqueue mode to use.
         - radio_mode: Enable radio mode for the given item(s).
         """
+        # ruff: noqa: PLR0915
         # pylint: disable=too-many-branches
         queue = self._queues[queue_id]
         if queue.announcement_in_progress:
@@ -219,6 +220,8 @@ class PlayerQueuesController:
             )
         # handle play: replace current loaded/playing index with new item(s)
         elif option == QueueOption.PLAY:
+            if cur_index <= len(self._queue_items[queue_id]) - 1:
+                cur_index = 0
             self.load(
                 queue_id,
                 queue_items=queue_items,
index 63da998d0228ac909a1b9bb5122e5b2c32948296..af3d669086ad91a5ec3e3ecbb1bc5dfd7ec4155c 100644 (file)
@@ -15,7 +15,12 @@ from music_assistant.common.models.enums import ConfigEntryType
 from music_assistant.common.models.errors import SetupFailedError
 from music_assistant.constants import CONF_PATH
 
-from .base import CONF_ENTRY_MISSING_ALBUM_ARTIST, FileSystemItem, FileSystemProviderBase
+from .base import (
+    CONF_ENTRY_MISSING_ALBUM_ARTIST,
+    IGNORE_DIRS,
+    FileSystemItem,
+    FileSystemProviderBase,
+)
 from .helpers import get_absolute_path, get_relative_path
 
 if TYPE_CHECKING:
@@ -99,9 +104,10 @@ class LocalFileSystemProvider(FileSystemProviderBase):
         """
         abs_path = get_absolute_path(self.config.get_value(CONF_PATH), path)
         for entry in await asyncio.to_thread(os.scandir, abs_path):
-            if entry.name.startswith("."):
+            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)
             if recursive and item.is_dir:
                 try:
index 2949b1a4c68f277955f44f6865f4a31cf098ce0b..103486a12c3a03c01c945fd43c658635f5d6c2b1 100644 (file)
@@ -69,6 +69,7 @@ PLAYLIST_EXTENSIONS = ("m3u", "pls")
 SUPPORTED_EXTENSIONS = TRACK_EXTENSIONS + PLAYLIST_EXTENSIONS
 IMAGE_EXTENSIONS = ("jpg", "jpeg", "JPG", "JPEG", "png", "PNG", "gif", "GIF")
 SEEKABLE_FILES = (ContentType.MP3, ContentType.WAV, ContentType.FLAC)
+IGNORE_DIRS = ("recycle", "Recently-Snaphot")
 
 SUPPORTED_FEATURES = (
     ProviderFeature.LIBRARY_ARTISTS,
@@ -530,7 +531,7 @@ class FileSystemProviderBase(MusicProvider):
         file_item = await self.resolve(item_id)
 
         return StreamDetails(
-            provider=self.domain,
+            provider=self.instance_id,
             item_id=item_id,
             content_type=prov_mapping.content_type,
             media_type=MediaType.TRACK,
index e0d1cce56bd479cb55d1e0d833a40f184776157e..16ad423a782f7d241e0aa9acd1e17eb9bd07f2b1 100644 (file)
@@ -1,7 +1,7 @@
 {
   "type": "music",
   "domain": "filesystem_local",
-  "name": "Local Filesystem",
+  "name": "Filesystem (local disk)",
   "description": "Support for music files that are present on a local accessible disk/folder.",
   "codeowners": ["@music-assistant"],
   "requirements": [],
index ab47aedf3e59687038e0cd5363334faf62730911..87c685f54ba93fd6fe3f34a9a829922bd5fa8f46 100644 (file)
@@ -1,22 +1,23 @@
 """SMB filesystem provider for Music Assistant."""
 from __future__ import annotations
 
+import asyncio
 import logging
 import os
 from collections.abc import AsyncGenerator
-from contextlib import asynccontextmanager
+from os.path import basename
 from typing import TYPE_CHECKING
 
-from smb.base import SharedFile
+import smbclient
+from smbclient import path as smbpath
 
-from music_assistant.common.helpers.util import get_ip_from_host
-from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueOption
+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 (
     CONF_ENTRY_MISSING_ALBUM_ARTIST,
+    IGNORE_DIRS,
     FileSystemItem,
     FileSystemProviderBase,
 )
@@ -25,8 +26,6 @@ from music_assistant.server.providers.filesystem_local.helpers import (
     get_relative_path,
 )
 
-from .helpers import AsyncSMB
-
 if TYPE_CHECKING:
     from music_assistant.common.models.config_entries import ProviderConfig
     from music_assistant.common.models.provider import ProviderManifest
@@ -42,6 +41,9 @@ 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")
     prov = SMBFileSystemProvider(mass, manifest, config)
     await prov.handle_setup()
     return prov
@@ -95,124 +97,63 @@ 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="domain",
-            type=ConfigEntryType.STRING,
-            label="Domain",
-            required=False,
-            advanced=True,
-            default_value="",
-            description="The network domain. On windows, it is known as the workgroup. "
-            "Usually, it is safe to leave this parameter as an empty string.",
-        ),
-        ConfigEntry(
-            key="use_ntlm_v2",
-            type=ConfigEntryType.BOOLEAN,
-            label="Use NTLM v2",
-            required=False,
-            advanced=True,
-            default_value=False,
-            description="Indicates whether NTLMv1 or NTLMv2 authentication algorithm should "
-            "be used for authentication. The choice of NTLMv1 and NTLMv2 is configured on "
-            "the remote server, and there is no mechanism to auto-detect which algorithm has "
-            "been configured. Hence, we can only “guess” or try both algorithms. On Sambda, "
-            "Windows Vista and Windows 7, NTLMv2 is enabled by default. "
-            "On Windows XP, we can use NTLMv1 before NTLMv2.",
-        ),
-        ConfigEntry(
-            key="sign_options",
-            type=ConfigEntryType.INTEGER,
-            label="Sign Options",
-            required=False,
-            advanced=True,
-            default_value=2,
-            options=(
-                ConfigValueOption("SIGN_NEVER", 0),
-                ConfigValueOption("SIGN_WHEN_SUPPORTED", 1),
-                ConfigValueOption("SIGN_WHEN_REQUIRED", 2),
-            ),
-            description="Determines whether SMB messages will be signed. "
-            "Default is SIGN_WHEN_REQUIRED. If SIGN_WHEN_REQUIRED (value=2), "
-            "SMB messages will only be signed when remote server requires signing. "
-            "If SIGN_WHEN_SUPPORTED (value=1), SMB messages will be signed when "
-            "remote server supports signing but not requires signing. "
-            "If SIGN_NEVER (value=0), SMB messages will never be signed regardless "
-            "of remote server’s configurations; access errors will occur if the "
-            "remote server requires signing.",
-        ),
-        ConfigEntry(
-            key="is_direct_tcp",
-            type=ConfigEntryType.BOOLEAN,
-            label="Use Direct TCP",
-            required=False,
-            advanced=True,
-            default_value=False,
-            description="Controls whether the NetBIOS over TCP/IP (is_direct_tcp=False) "
-            "or the newer Direct hosting of SMB over TCP/IP (is_direct_tcp=True) will "
-            "be used for the communication. The default parameter is False which will "
-            "use NetBIOS over TCP/IP for wider compatibility (TCP port: 139).",
-        ),
         CONF_ENTRY_MISSING_ALBUM_ARTIST,
     )
 
 
-async def create_item(file_path: str, entry: SharedFile, root_path: str) -> FileSystemItem:
-    """Create FileSystemItem from smb.SharedFile."""
-    rel_path = get_relative_path(root_path, file_path)
-    abs_path = get_absolute_path(root_path, file_path)
-    return FileSystemItem(
-        name=entry.filename,
-        path=rel_path,
-        absolute_path=abs_path,
-        is_file=not entry.isDirectory,
-        is_dir=entry.isDirectory,
-        checksum=str(int(entry.last_write_time)),
-        file_size=entry.file_size,
-    )
+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(FileSystemProviderBase):
     """Implementation of an SMB File System Provider."""
 
-    _service_name = ""
-    _root_path = "/"
-    _remote_name = ""
-    _target_ip = ""
-
     async def handle_setup(self) -> None:
         """Handle async initialization of the provider."""
         # silence SMB.SMBConnection logger a bit
         logging.getLogger("SMB.SMBConnection").setLevel("WARNING")
 
-        self._remote_name = self.config.get_value(CONF_HOST)
-        self._service_name = self.config.get_value(CONF_SHARE)
-
-        # validate provided path
+        server: str = self.config.get_value(CONF_HOST)
+        share: str = self.config.get_value(CONF_SHARE)
         subfolder: str = self.config.get_value(CONF_SUBFOLDER)
-        subfolder.replace("\\", "/")
-        if not subfolder.startswith("/"):
-            subfolder = "/" + subfolder
-        if not subfolder.endswith("/"):
-            subfolder += "/"
-        self._root_path = subfolder
-
-        # resolve dns name to IP
-        target_ip = await get_ip_from_host(self._remote_name)
-        if target_ip is None:
-            raise LoginFailed(
-                f"Unable to resolve {self._remote_name}, maybe use an IP address as remote host ?"
-            )
-        self._target_ip = target_ip
 
-        # test connection and return
-        # this code will raise if the connection did not succeed
-        async with self._get_smb_connection():
-            return
+        # register smb session
+        self.logger.info("Connecting to server %s", server)
+        self._session = await asyncio.to_thread(
+            smbclient.register_session,
+            server,
+            username=self.config.get_value(CONF_USERNAME),
+            password=self.config.get_value(CONF_PASSWORD),
+        )
+
+        # create windows like path (\\server\share\subfolder)
+        if subfolder.endswith(os.sep):
+            subfolder = subfolder[:-1]
+        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)
+        # validate provided path
+        if not await asyncio.to_thread(smbpath.isdir, self._root_path):
+            raise LoginFailed(f"Invalid share or subfolder given: {self._root_path}")
 
     async def listdir(
-        self,
-        path: str,
-        recursive: bool = False,
+        self, path: str, recursive: bool = False
     ) -> AsyncGenerator[FileSystemItem, None]:
         """List contents of a given provider directory/path.
 
@@ -220,7 +161,7 @@ class SMBFileSystemProvider(FileSystemProviderBase):
         ----------
         - 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)
+        - recursive: If True will recursively keep unwrapping subdirectories (scandir equivalent).
 
         Returns:
         -------
@@ -228,78 +169,88 @@ class SMBFileSystemProvider(FileSystemProviderBase):
 
         """
         abs_path = get_absolute_path(self._root_path, path)
-        async with self._get_smb_connection() as smb_conn:
-            path_result: list[SharedFile] = await smb_conn.list_path(abs_path)
-
-        for entry in path_result:
-            if entry.filename.startswith("."):
+        for entry in await asyncio.to_thread(smbclient.scandir, abs_path):
+            if entry.name.startswith(".") or any(x in entry.name for x in IGNORE_DIRS):
                 # skip invalid/system files and dirs
                 continue
-            file_path = os.path.join(path, entry.filename)
-            item = await create_item(file_path, entry, self._root_path)
+            item = await create_item(self._root_path, entry)
             if recursive and item.is_dir:
-                # yield sublevel recursively
                 try:
-                    async for subitem in self.listdir(file_path, True):
+                    async for subitem in self.listdir(item.absolute_path, True):
                         yield subitem
                 except (OSError, PermissionError) as err:
                     self.logger.warning("Skip folder %s: %s", item.path, str(err))
             else:
-                # yield single item (file or directory)
                 yield item
 
-    async def resolve(self, file_path: str) -> FileSystemItem:
-        """Resolve (absolute or relative) path to FileSystemItem."""
-        abs_path = get_absolute_path(self._root_path, file_path)
-        async with self._get_smb_connection() as smb_conn:
-            entry: SharedFile = await smb_conn.get_attributes(abs_path)
+    async def resolve(
+        self, file_path: str, require_local: bool = False  # noqa: ARG002
+    ) -> FileSystemItem:
+        """Resolve (absolute or relative) path to FileSystemItem.
+
+        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=file_path,
+                name=basename(file_path),
                 path=get_relative_path(self._root_path, file_path),
-                absolute_path=abs_path,
-                is_file=not entry.isDirectory,
-                is_dir=entry.isDirectory,
-                checksum=str(int(entry.last_write_time)),
-                file_size=entry.file_size,
+                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,
             )
 
-    @use_cache(15 * 60)
+        # run in thread because strictly taken this may be blocking IO
+        return await asyncio.to_thread(_create_item)
+
     async def exists(self, file_path: str) -> bool:
-        """Return bool if this FileSystem musicprovider has given file/dir."""
+        """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)
-        async with self._get_smb_connection() as smb_conn:
-            return await smb_conn.path_exists(abs_path)
+        return await asyncio.to_thread(smbpath.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."""
+        file_path = file_path.replace("\\", os.sep)
         abs_path = get_absolute_path(self._root_path, file_path)
-
-        async with self._get_smb_connection() as smb_conn:
-            async for chunk in smb_conn.retrieve_file(abs_path, seek):
-                yield chunk
+        chunk_size = 512000
+        queue = asyncio.Queue()
+        self.logger.debug("Reading file contents for %s", abs_path)
+
+        def _reader():
+            with smbclient.open_file(abs_path, "rb", share_access="r") as _file:
+                if seek:
+                    _file.seek(seek)
+                # yield chunks of data from file
+                while True:
+                    data = _file.read(chunk_size)
+                    if not data:
+                        break
+                    self.mass.loop.call_soon_threadsafe(queue.put_nowait, data)
+            self.mass.loop.call_soon_threadsafe(queue.put_nowait, b"")
+
+        self.mass.create_task(_reader)
+        while True:
+            chunk = await queue.get()
+            if chunk == b"":
+                break
+            yield chunk
 
     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)
-        async with self._get_smb_connection() as smb_conn:
-            await smb_conn.write_file(abs_path, data)
-
-    @asynccontextmanager
-    async def _get_smb_connection(self) -> AsyncGenerator[AsyncSMB, None]:
-        """Get instance of AsyncSMB."""
-        # For now we just create a connection per call
-        # as that is the most reliable (but a bit slower)
-        # this could be improved by creating a connection pool
-        # if really needed
-
-        async with AsyncSMB(
-            remote_name=self._remote_name,
-            service_name=self._service_name,
-            username=self.config.get_value(CONF_USERNAME),
-            password=self.config.get_value(CONF_PASSWORD),
-            target_ip=self._target_ip,
-            use_ntlm_v2=self.config.get_value("use_ntlm_v2"),
-            sign_options=self.config.get_value("sign_options"),
-            is_direct_tcp=self.config.get_value("is_direct_tcp"),
-        ) as smb_conn:
-            yield smb_conn
+
+        def _writer():
+            with smbclient.open_file(abs_path, "wb") as _file:
+                _file.write(data)
+
+        await asyncio.to_thread(_writer)
diff --git a/music_assistant/server/providers/filesystem_smb/helpers.py b/music_assistant/server/providers/filesystem_smb/helpers.py
deleted file mode 100644 (file)
index 8baed53..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-"""Some helpers for Filesystem based Musicproviders."""
-from __future__ import annotations
-
-import asyncio
-from collections.abc import AsyncGenerator
-from io import BytesIO
-
-from smb.base import OperationFailure, SharedFile
-from smb.SMBConnection import SMBConnection
-
-from music_assistant.common.models.errors import LoginFailed
-
-SERVICE_NAME = "music_assistant"
-
-
-class AsyncSMB:
-    """Async wrapped pysmb."""
-
-    def __init__(
-        self,
-        remote_name: str,
-        service_name: str,
-        username: str,
-        password: str,
-        target_ip: str,
-        use_ntlm_v2: bool = True,
-        sign_options: int = 2,
-        is_direct_tcp: bool = False,
-    ) -> None:
-        """Initialize instance."""
-        self._service_name = service_name
-        self._remote_name = remote_name
-        self._target_ip = target_ip
-        self._username = username
-        self._password = password
-        self._conn = SMBConnection(
-            username=self._username,
-            password=self._password,
-            my_name=SERVICE_NAME,
-            remote_name=self._remote_name,
-            use_ntlm_v2=use_ntlm_v2,
-            sign_options=sign_options,
-            is_direct_tcp=is_direct_tcp,
-        )
-        # the smb connection may not be used asynchronously and
-        # each operation should take sequentially.
-        # to support this, we use a Lock and we create a new.
-        self._lock = asyncio.Lock()
-
-    async def list_path(self, path: str) -> list[SharedFile]:
-        """Retrieve a directory listing of files/folders at *path*."""
-        async with self._lock:
-            return await asyncio.to_thread(self._conn.listPath, self._service_name, path)
-
-    async def get_attributes(self, path: str) -> SharedFile:
-        """Retrieve information about the file at *path* on the *service_name*."""
-        async with self._lock:
-            return await asyncio.to_thread(self._conn.getAttributes, self._service_name, path)
-
-    async def retrieve_file(self, path: str, offset: int = 0) -> AsyncGenerator[bytes, None]:
-        """Retrieve file contents."""
-        chunk_size = 256000
-        while True:
-            async with self._lock:
-                with BytesIO() as file_obj:
-                    await asyncio.to_thread(
-                        self._conn.retrieveFileFromOffset,
-                        self._service_name,
-                        path,
-                        file_obj,
-                        offset,
-                        chunk_size,
-                    )
-                    file_obj.seek(0)
-                    chunk = file_obj.read()
-                    yield chunk
-                    offset += len(chunk)
-                    if len(chunk) < chunk_size:
-                        break
-
-    async def write_file(self, path: str, data: bytes) -> SharedFile:
-        """Store the contents to the file at *path*."""
-        with BytesIO() as file_obj:
-            file_obj.write(data)
-            file_obj.seek(0)
-            async with self._lock:
-                await asyncio.to_thread(
-                    self._conn.storeFile,
-                    self._service_name,
-                    path,
-                    file_obj,
-                )
-
-    async def path_exists(self, path: str) -> bool:
-        """Return bool is this FileSystem musicprovider has given file/dir."""
-        async with self._lock:
-            try:
-                await asyncio.to_thread(self._conn.getAttributes, self._service_name, path)
-            except (OperationFailure,):
-                return False
-            except IndexError:
-                return False
-            return True
-
-    async def connect(self) -> None:
-        """Connect to the SMB server."""
-        async with self._lock:
-            try:
-                assert await asyncio.to_thread(self._conn.connect, self._target_ip) is True
-            except Exception as exc:
-                raise LoginFailed(f"SMB Connect failed to {self._remote_name}") from exc
-
-    async def __aenter__(self) -> AsyncSMB:
-        """Enter context manager."""
-        # connect
-        await self.connect()
-        return self
-
-    async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
-        """Exit context manager."""
-        self._conn.close()
index cbba85f5f9c277a73ec024f4111633b2bb86b083..065332b84021c9622092e3e3f0ded13900d470f6 100644 (file)
@@ -1,10 +1,10 @@
 {
   "type": "music",
   "domain": "filesystem_smb",
-  "name": "SMB Filesystem",
-  "description": "Support for music files that are present on remote SMB/CIFS share.",
-  "codeowners": ["@MarvinSchenkel"],
-  "requirements": ["pysmb==1.2.9.1"],
+  "name": "Filesystem (remote share)",
+  "description": "Support for music files that are present on remote SMB/CIFS or DFS share.",
+  "codeowners": ["@music-assistant"],
+  "requirements": ["smbprotocol[kerberos]==1.10.1"],
   "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/820",
   "multi_instance": true,
   "icon": "mdi:mdi-network"
index 2e693f3529bc1afa43ae6903a2eea757241f97c0..04749e6a658d536adaf749d41c3480a99f507f39 100644 (file)
@@ -363,7 +363,7 @@ class QobuzProvider(MusicProvider):
         self.mass.create_task(self._report_playback_started(streamdata))
         return StreamDetails(
             item_id=str(item_id),
-            provider=self.domain,
+            provider=self.instance_id,
             content_type=content_type,
             duration=streamdata["duration"],
             sample_rate=int(streamdata["sampling_rate"] * 1000),
index 90191fc0e9e69919038f1afdbe4a70cc26e0ea47..ab070e35206515fae46d37e1dfd9b5b1b661ded6 100644 (file)
@@ -251,7 +251,7 @@ class SoundcloudMusicProvider(MusicProvider):
         stream_format = track_details[0]["media"]["transcodings"][0]["format"]["mime_type"]
         url = await self._soundcloud.get_stream_url(track_id=item_id)
         return StreamDetails(
-            provider=self.domain,
+            provider=self.instance_id,
             item_id=item_id,
             content_type=ContentType.try_parse(stream_format),
             direct=url,
index df077639b1d4a58be29e0a1b4961315a56246c48..147e42d2624c3da3a10f585e782fd36859fe1d89 100644 (file)
@@ -343,7 +343,7 @@ class SpotifyProvider(MusicProvider):
         await self.login()
         return StreamDetails(
             item_id=track.item_id,
-            provider=self.domain,
+            provider=self.instance_id,
             content_type=ContentType.OGG,
             duration=track.duration,
         )
index 9d1fa7787f44c0a553c381e2555c7733bf910b06..89c6a163cc53226309937226f78a596b7433e8b0 100644 (file)
@@ -188,7 +188,7 @@ class TuneInProvider(MusicProvider):
         if item_id.startswith("http"):
             # custom url
             return StreamDetails(
-                provider=self.domain,
+                provider=self.instance_id,
                 item_id=item_id,
                 content_type=ContentType.UNKNOWN,
                 media_type=MediaType.RADIO,
index a9e8497e41e2819cf64c035f3a6ba4b8ea90cd83..c3f2a36c533e2238970a86b24477943a5b8ffec0 100644 (file)
@@ -164,7 +164,7 @@ class URLProvider(MusicProvider):
         item_id, url, media_info = await self._get_media_info(item_id)
         is_radio = media_info.get("icy-name") or not media_info.duration
         return StreamDetails(
-            provider=self.domain,
+            provider=self.instance_id,
             item_id=item_id,
             content_type=ContentType.try_parse(media_info.format),
             media_type=MediaType.RADIO if is_radio else MediaType.TRACK,
index 407e6478cb8b45be2e8604eb8066cef4770ede4b..4b37d2d9002b4c65a0d1d871d9efba89574c9104 100644 (file)
@@ -429,7 +429,7 @@ class YoutubeMusicProvider(MusicProvider):
         stream_format = await self._parse_stream_format(track_obj)
         url = await self._parse_stream_url(stream_format=stream_format, item_id=item_id)
         stream_details = StreamDetails(
-            provider=self.domain,
+            provider=self.instance_id,
             item_id=item_id,
             content_type=ContentType.try_parse(stream_format["mimeType"]),
             direct=url,
index 90a5ed3c49bf84b9dbc0391b374c5ab77b0774e4..7f4589c5f5299611c74fa284b136c804577417c7 100644 (file)
@@ -112,7 +112,7 @@ class MusicAssistant:
             task.cancel()
         # cleanup all providers
         for prov_id in list(self._providers.keys()):
-            await self.unload_provider(prov_id)
+            asyncio.create_task(self.unload_provider(prov_id))
         # stop core controllers
         await self.streams.close()
         await self.webserver.close()
index 1ee73863877108ef6d41eb6d762f97592facd00d..a62cb558305134e125e7f18e901571aab543ef9e 100644 (file)
@@ -17,9 +17,9 @@ music-assistant-frontend==20230324.0
 orjson==3.8.7
 pillow==9.4.0
 PyChromecast==13.0.5
-pysmb==1.2.9.1
 python-slugify==8.0.1
 shortuuid==1.0.11
+smbprotocol[kerberos]==1.10.1
 soco==0.29.1
 unidecode==1.3.6
 xmltodict==0.13.0