Performance improvements for filesystem provider (#1844)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 9 Jan 2025 01:01:48 +0000 (02:01 +0100)
committerGitHub <noreply@github.com>
Thu, 9 Jan 2025 01:01:48 +0000 (02:01 +0100)
music_assistant/controllers/players.py
music_assistant/helpers/tags.py
music_assistant/providers/builtin/__init__.py
music_assistant/providers/filesystem_local/__init__.py
music_assistant/providers/filesystem_local/helpers.py
music_assistant/providers/hass_players/__init__.py
music_assistant/providers/plex/__init__.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/tidal/__init__.py
tests/core/test_tags.py

index bfcf1a54f1902f9b9410419da7e799cd583cbafa..72dc2276f9b7d43ecf15e1aed4ee75f7c168cfff 100644 (file)
@@ -44,7 +44,7 @@ from music_assistant.constants import (
     CONF_TTS_PRE_ANNOUNCE,
 )
 from music_assistant.helpers.api import api_command
-from music_assistant.helpers.tags import parse_tags
+from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.helpers.throttle_retry import Throttler
 from music_assistant.helpers.uri import parse_uri
 from music_assistant.helpers.util import TaskManager, get_changed_values
@@ -1309,7 +1309,7 @@ class PlayerController(CoreController):
         await self.wait_for_state(player, PlayerState.PLAYING, 10, minimal_time=0.1)
         # wait for the player to stop playing
         if not announcement.duration:
-            media_info = await parse_tags(announcement.custom_data["url"])
+            media_info = await async_parse_tags(announcement.custom_data["url"])
             announcement.duration = media_info.duration or 60
         media_info.duration += 2
         await self.wait_for_state(
index 55def74b6f4623927fc45d1b5f6d367c9f8b14ec..60aa3bfa175215c06b31648699ad81e4b7de20f5 100644 (file)
@@ -6,6 +6,7 @@ import asyncio
 import json
 import logging
 import os
+import subprocess
 from collections.abc import Iterable
 from dataclasses import dataclass
 from json import JSONDecodeError
@@ -380,9 +381,14 @@ class AudioTags:
         return self.tags.get(key, default)
 
 
-async def parse_tags(input_file: str, file_size: int | None = None) -> AudioTags:
+async def async_parse_tags(input_file: str, file_size: int | None = None) -> AudioTags:
+    """Parse tags from a media file (or URL). Async friendly."""
+    return await asyncio.to_thread(parse_tags, input_file, file_size)
+
+
+def parse_tags(input_file: str, file_size: int | None = None) -> AudioTags:
     """
-    Parse tags from a media file (or URL).
+    Parse tags from a media file (or URL). NOT Async friendly.
 
     Input_file may be a (local) filename or URL accessible by ffmpeg.
     """
@@ -402,9 +408,8 @@ async def parse_tags(input_file: str, file_size: int | None = None) -> AudioTags
         "-i",
         input_file,
     )
-    async with AsyncProcess(args, stdin=False, stdout=True) as ffmpeg:
-        res = await ffmpeg.read(-1)
     try:
+        res = subprocess.check_output(args)  # noqa: S603
         data = json.loads(res)
         if error := data.get("error"):
             raise InvalidDataError(error["string"])
@@ -424,12 +429,13 @@ async def parse_tags(input_file: str, file_size: int | None = None) -> AudioTags
             not input_file.startswith("http")
             and input_file.endswith(".mp3")
             and "musicbrainzrecordingid" not in tags.tags
-            and await asyncio.to_thread(os.path.isfile, input_file)
+            and os.path.isfile(input_file)
         ):
             # eyed3 is able to extract the musicbrainzrecordingid from the unique file id
             # this is actually a bug in ffmpeg/ffprobe which does not expose this tag
             # so we use this as alternative approach for mp3 files
-            audiofile = await asyncio.to_thread(eyed3.load, input_file)
+            # TODO: Convert all the tag reading to Mutagen!
+            audiofile = eyed3.load(input_file)
             if audiofile is not None and audiofile.tag is not None:
                 for uf_id in audiofile.tag.unique_file_ids:
                     if uf_id.owner_id == b"http://musicbrainz.org" and uf_id.uniq_id:
index ee1b57be55f0ce4684d1feadc6bce81adaea3c6b..25aa0c6222b9d165bb6201103e5c58fd2814e0f0 100644 (file)
@@ -40,7 +40,7 @@ from music_assistant_models.media_items import (
 from music_assistant_models.streamdetails import StreamDetails
 
 from music_assistant.constants import MASS_LOGO, VARIOUS_ARTISTS_FANART
-from music_assistant.helpers.tags import AudioTags, parse_tags
+from music_assistant.helpers.tags import AudioTags, async_parse_tags
 from music_assistant.helpers.uri import parse_uri
 from music_assistant.models.music_provider import MusicProvider
 
@@ -533,7 +533,7 @@ class BuiltinProvider(MusicProvider):
         if cached_info and not force_refresh:
             return AudioTags.parse(cached_info)
         # parse info with ffprobe (and store in cache)
-        media_info = await parse_tags(url)
+        media_info = await async_parse_tags(url)
         if "authSig" in url:
             media_info.has_cover_image = False
         await self.mass.cache.set(
index c10b20f30d4c19d67efa6da16b352bbba5590e6e..fa746715f112af5e7f101c38e10b7ec987f2acca 100644 (file)
@@ -7,17 +7,15 @@ import contextlib
 import logging
 import os
 import os.path
+import time
+from collections.abc import Iterator
 from typing import TYPE_CHECKING, cast
 
 import aiofiles
 import shortuuid
 import xmltodict
 from aiofiles.os import wrap
-from music_assistant_models.config_entries import (
-    ConfigEntry,
-    ConfigValueOption,
-    ConfigValueType,
-)
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
@@ -26,11 +24,7 @@ from music_assistant_models.enums import (
     ProviderFeature,
     StreamType,
 )
-from music_assistant_models.errors import (
-    MediaNotFoundError,
-    MusicAssistantError,
-    SetupFailedError,
-)
+from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError, SetupFailedError
 from music_assistant_models.media_items import (
     Album,
     Artist,
@@ -62,11 +56,12 @@ from music_assistant.constants import (
 )
 from music_assistant.helpers.compare import compare_strings, create_safe_string
 from music_assistant.helpers.playlists import parse_m3u, parse_pls
-from music_assistant.helpers.tags import AudioTags, parse_tags, split_items
-from music_assistant.helpers.util import TaskManager, parse_title_and_version
+from music_assistant.helpers.tags import AudioTags, async_parse_tags, parse_tags, split_items
+from music_assistant.helpers.util import parse_title_and_version
 from music_assistant.models.music_provider import MusicProvider
 
 from .helpers import (
+    IGNORE_DIRS,
     FileSystemItem,
     get_absolute_path,
     get_album_dir,
@@ -76,8 +71,6 @@ from .helpers import (
 )
 
 if TYPE_CHECKING:
-    from collections.abc import AsyncGenerator
-
     from music_assistant_models.config_entries import ProviderConfig
     from music_assistant_models.provider import ProviderManifest
 
@@ -128,11 +121,11 @@ SUPPORTED_FEATURES = {
     ProviderFeature.SEARCH,
 }
 
-listdir = wrap(os.listdir)
 isdir = wrap(os.path.isdir)
 isfile = wrap(os.path.isfile)
 exists = wrap(os.path.exists)
 makedirs = wrap(os.makedirs)
+scandir = wrap(os.scandir)
 
 
 async def setup(
@@ -185,7 +178,7 @@ class LocalFileSystemProvider(MusicProvider):
 
     base_path: str
     write_access: bool = False
-    scan_limiter = asyncio.Semaphore(25)
+    sync_running: bool = False
 
     @property
     def supported_features(self) -> set[ProviderFeature]:
@@ -248,7 +241,8 @@ class LocalFileSystemProvider(MusicProvider):
         item_path = path.split("://", 1)[1]
         if not item_path:
             item_path = ""
-        async for item in self.listdir(item_path, recursive=False, sort=True):
+        abs_path = self.get_absolute_path(item_path)
+        for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True):
             if not item.is_dir and ("." not in item.filename or not item.ext):
                 # skip system files and files without extension
                 continue
@@ -256,9 +250,9 @@ class LocalFileSystemProvider(MusicProvider):
             if item.is_dir:
                 items.append(
                     BrowseFolder(
-                        item_id=item.path,
+                        item_id=item.relative_path,
                         provider=self.instance_id,
-                        path=f"{self.instance_id}://{item.path}",
+                        path=f"{self.instance_id}://{item.relative_path}",
                         name=item.filename,
                     )
                 )
@@ -266,7 +260,7 @@ class LocalFileSystemProvider(MusicProvider):
                 items.append(
                     ItemMapping(
                         media_type=MediaType.TRACK,
-                        item_id=item.path,
+                        item_id=item.relative_path,
                         provider=self.instance_id,
                         name=item.filename,
                     )
@@ -275,7 +269,7 @@ class LocalFileSystemProvider(MusicProvider):
                 items.append(
                     ItemMapping(
                         media_type=MediaType.PLAYLIST,
-                        item_id=item.path,
+                        item_id=item.relative_path,
                         provider=self.instance_id,
                         name=item.filename,
                     )
@@ -285,6 +279,14 @@ class LocalFileSystemProvider(MusicProvider):
     async def sync_library(self, media_types: tuple[MediaType, ...]) -> None:
         """Run library sync for this provider."""
         assert self.mass.music.database
+        start_time = time.time()
+        if self.sync_running:
+            self.logger.warning("Library sync already running for %s", self.name)
+            return
+        self.logger.info(
+            "Started Library sync for %s",
+            self.name,
+        )
         file_checksums: dict[str, str] = {}
         query = (
             f"SELECT provider_item_id, details FROM {DB_TABLE_PROVIDER_MAPPINGS} "
@@ -297,25 +299,50 @@ class LocalFileSystemProvider(MusicProvider):
         # we work bottom up, as-in we derive all info from the tracks
         cur_filenames = set()
         prev_filenames = set(file_checksums.keys())
-        async with TaskManager(self.mass, 25) as tm:
-            async for item in self.listdir("", recursive=True, sort=False):
-                if "." not in item.filename or not item.ext:
-                    # skip system files and files without extension
-                    continue
 
-                if item.ext not in SUPPORTED_EXTENSIONS:
-                    # unsupported file extension
+        # NOTE: we do the entire traversing of the directory structure, including parsing tags
+        # in a single executor threads to save the overhead of having to spin up tons of tasks
+        def listdir(path: str) -> Iterator[FileSystemItem]:
+            """Recursively traverse directory entries."""
+            for item in os.scandir(path):
+                # ignore invalid filenames
+                if item.name in IGNORE_DIRS or item.name.startswith((".", "_")):
                     continue
+                if item.is_dir(follow_symlinks=False):
+                    yield from listdir(item.path)
+                elif item.is_file(follow_symlinks=False):
+                    # skip files without extension
+                    if "." not in item.name:
+                        continue
+                    yield FileSystemItem.from_dir_entry(item, self.base_path)
+
+        def run_sync() -> None:
+            """Run the actual sync (in an executor job)."""
+            self.sync_running = True
+            try:
+                for item in listdir(self.base_path):
+                    if item.ext not in SUPPORTED_EXTENSIONS:
+                        # unsupported file extension
+                        continue
 
-                cur_filenames.add(item.path)
+                    cur_filenames.add(item.relative_path)
 
-                # continue if the item did not change (checksum still the same)
-                prev_checksum = file_checksums.get(item.path)
-                if item.checksum == prev_checksum:
-                    continue
+                    # continue if the item did not change (checksum still the same)
+                    prev_checksum = file_checksums.get(item.relative_path)
+                    if item.checksum == prev_checksum:
+                        continue
+                    self._process_item(item, prev_checksum)
+            finally:
+                self.sync_running = False
 
-                await tm.create_task_with_limit(self._process_item(item, prev_checksum))
+        await asyncio.to_thread(run_sync)
 
+        end_time = time.time()
+        self.logger.info(
+            "Library sync for %s completed in %.2f seconds",
+            self.name,
+            end_time - start_time,
+        )
         # work out deletions
         deleted_files = prev_filenames - cur_filenames
         await self._process_deletions(deleted_files)
@@ -323,33 +350,47 @@ class LocalFileSystemProvider(MusicProvider):
         # process orphaned albums and artists
         await self._process_orphaned_albums_and_artists()
 
-    async def _process_item(self, item: FileSystemItem, prev_checksum: str | None) -> None:
-        """Process a single item."""
+    def _process_item(self, item: FileSystemItem, prev_checksum: str | None) -> None:
+        """Process a single item. NOT async friendly."""
         try:
-            self.logger.debug("Processing: %s", item.path)
+            self.logger.debug("Processing: %s", item.relative_path)
             if item.ext in TRACK_EXTENSIONS:
-                # add/update track to db
-                # note that filesystem items are always overwriting existing info
-                # when they are detected as changed
-                track = await self._parse_track(item)
-                await self.mass.music.tracks.add_item_to_library(
-                    track, overwrite_existing=prev_checksum is not None
-                )
-            elif item.ext in PLAYLIST_EXTENSIONS:
-                playlist = await self.get_playlist(item.path)
-                # add/update] playlist to db
-                playlist.cache_checksum = item.checksum
-                # playlist is always favorite
-                playlist.favorite = True
-                await self.mass.music.playlists.add_item_to_library(
-                    playlist,
-                    overwrite_existing=prev_checksum is not None,
-                )
+                # handle track item
+                tags = parse_tags(item.absolute_path, item.file_size)
+
+                async def process_track() -> None:
+                    track = await self._parse_track(item, tags)
+                    # add/update track to db
+                    # note that filesystem items are always overwriting existing info
+                    # when they are detected as changed
+                    await self.mass.music.tracks.add_item_to_library(
+                        track, overwrite_existing=prev_checksum is not None
+                    )
+
+                asyncio.run_coroutine_threadsafe(process_track(), self.mass.loop).result()
+                return
+
+            if item.ext in PLAYLIST_EXTENSIONS:
+
+                async def process_playlist() -> None:
+                    playlist = await self.get_playlist(item.relative_path)
+                    # add/update] playlist to db
+                    playlist.cache_checksum = item.checksum
+                    # playlist is always favorite
+                    playlist.favorite = True
+                    await self.mass.music.playlists.add_item_to_library(
+                        playlist,
+                        overwrite_existing=prev_checksum is not None,
+                    )
+
+                asyncio.run_coroutine_threadsafe(process_playlist(), self.mass.loop).result()
+                return
+
         except Exception as err:
             # we don't want the whole sync to crash on one file so we catch all exceptions here
             self.logger.error(
                 "Error processing %s - %s",
-                item.path,
+                item.relative_path,
                 str(err),
                 exc_info=err if self.logger.isEnabledFor(logging.DEBUG) else None,
             )
@@ -466,7 +507,8 @@ class LocalFileSystemProvider(MusicProvider):
             for prov_mapping in track.provider_mappings:
                 if prov_mapping.provider_instance == self.instance_id:
                     file_item = await self.resolve(prov_mapping.item_id)
-                    full_track = await self._parse_track(file_item)
+                    tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
+                    full_track = await self._parse_track(file_item, tags)
                     assert isinstance(full_track.album, Album)
                     return full_track.album
         msg = f"Album not found: {prov_album_id}"
@@ -480,7 +522,8 @@ class LocalFileSystemProvider(MusicProvider):
             raise MediaNotFoundError(msg)
 
         file_item = await self.resolve(prov_track_id)
-        return await self._parse_track(file_item, full_album_metadata=True)
+        tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
+        return await self._parse_track(file_item, tags=tags, full_album_metadata=True)
 
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
         """Get full playlist details by id."""
@@ -490,12 +533,12 @@ class LocalFileSystemProvider(MusicProvider):
 
         file_item = await self.resolve(prov_playlist_id)
         playlist = Playlist(
-            item_id=file_item.path,
+            item_id=file_item.relative_path,
             provider=self.instance_id,
             name=file_item.name,
             provider_mappings={
                 ProviderMapping(
-                    item_id=file_item.path,
+                    item_id=file_item.relative_path,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     details=file_item.checksum,
@@ -582,8 +625,9 @@ class LocalFileSystemProvider(MusicProvider):
             # try to resolve the filename
             for filename in (line, os.path.join(playlist_path, line)):
                 with contextlib.suppress(FileNotFoundError):
-                    item = await self.resolve(filename)
-                    return await self._parse_track(item)
+                    file_item = await self.resolve(filename)
+                    tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
+                    return await self._parse_track(file_item, tags)
 
         except MusicAssistantError as err:
             self.logger.warning("Could not parse uri/file %s to track: %s", line, str(err))
@@ -655,7 +699,8 @@ class LocalFileSystemProvider(MusicProvider):
         if library_item is None:
             # this could be a file that has just been added, try parsing it
             file_item = await self.resolve(item_id)
-            if not (library_item := await self._parse_track(file_item)):
+            tags = await async_parse_tags(file_item.absolute_path, file_item.file_size)
+            if not (library_item := await self._parse_track(file_item, tags)):
                 msg = f"Item not found: {item_id}"
                 raise MediaNotFoundError(msg)
 
@@ -686,23 +731,20 @@ class LocalFileSystemProvider(MusicProvider):
         return file_item.absolute_path
 
     async def _parse_track(
-        self, file_item: FileSystemItem, full_album_metadata: bool = False
+        self, file_item: FileSystemItem, tags: AudioTags, full_album_metadata: bool = False
     ) -> Track:
-        """Get full track details by id."""
+        """Get full track details by id. NOT async friendly."""
         # ruff: noqa: PLR0915, PLR0912
-
-        # parse tags
-        tags = await parse_tags(file_item.absolute_path, file_item.file_size)
         name, version = parse_title_and_version(tags.title, tags.version)
         track = Track(
-            item_id=file_item.path,
+            item_id=file_item.relative_path,
             provider=self.instance_id,
             name=name,
             sort_name=tags.title_sort,
             version=version,
             provider_mappings={
                 ProviderMapping(
-                    item_id=file_item.path,
+                    item_id=file_item.relative_path,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     audio_format=AudioFormat(
@@ -728,7 +770,7 @@ class LocalFileSystemProvider(MusicProvider):
 
         # album
         album = track.album = (
-            await self._parse_album(track_path=file_item.path, track_tags=tags)
+            await self._parse_album(track_path=file_item.relative_path, track_tags=tags)
             if tags.album
             else None
         )
@@ -765,7 +807,7 @@ class LocalFileSystemProvider(MusicProvider):
                 [
                     MediaItemImage(
                         type=ImageType.THUMB,
-                        path=file_item.path,
+                        path=file_item.relative_path,
                         provider=self.instance_id,
                         remotely_accessible=False,
                     )
@@ -793,11 +835,13 @@ class LocalFileSystemProvider(MusicProvider):
 
         # handle (optional) loudness measurement tag(s)
         if tags.track_loudness is not None:
-            await self.mass.music.set_loudness(
-                track.item_id,
-                self.instance_id,
-                tags.track_loudness,
-                tags.track_album_loudness,
+            self.mass.create_task(
+                self.mass.music.set_loudness(
+                    track.item_id,
+                    self.instance_id,
+                    tags.track_loudness,
+                    tags.track_album_loudness,
+                )
             )
         return track
 
@@ -1039,8 +1083,9 @@ class LocalFileSystemProvider(MusicProvider):
     async def _get_local_images(self, folder: str) -> UniqueList[MediaItemImage]:
         """Return local images found in a given folderpath."""
         images: UniqueList[MediaItemImage] = UniqueList()
-        async for item in self.listdir(folder):
-            if "." not in item.path or item.is_dir:
+        abs_path = self.get_absolute_path(folder)
+        for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=False):
+            if "." not in item.relative_path or item.is_dir:
                 continue
             for ext in IMAGE_EXTENSIONS:
                 if item.ext != ext:
@@ -1050,7 +1095,7 @@ class LocalFileSystemProvider(MusicProvider):
                     images.append(
                         MediaItemImage(
                             type=ImageType(item.name),
-                            path=item.path,
+                            path=item.relative_path,
                             provider=self.instance_id,
                             remotely_accessible=False,
                         )
@@ -1062,7 +1107,7 @@ class LocalFileSystemProvider(MusicProvider):
                         images.append(
                             MediaItemImage(
                                 type=ImageType.THUMB,
-                                path=item.path,
+                                path=item.relative_path,
                                 provider=self.instance_id,
                                 remotely_accessible=False,
                             )
@@ -1083,33 +1128,6 @@ class LocalFileSystemProvider(MusicProvider):
         except Exception as err:
             self.logger.debug("Write access disabled: %s", str(err))
 
-    async def listdir(
-        self, path: str, recursive: bool = False, sort: 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 = self.get_absolute_path(path)
-        for entry in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort):
-            if recursive and entry.is_dir:
-                try:
-                    async for subitem in self.listdir(entry.absolute_path, True, sort):
-                        yield subitem
-                except (OSError, PermissionError) as err:
-                    self.logger.warning("Skip folder %s: %s", entry.path, str(err))
-            else:
-                yield entry
-
     async def resolve(
         self,
         file_path: str,
@@ -1118,13 +1136,19 @@ class LocalFileSystemProvider(MusicProvider):
         absolute_path = self.get_absolute_path(file_path)
 
         def _create_item() -> FileSystemItem:
+            if os.path.isdir(absolute_path):
+                return FileSystemItem(
+                    filename=os.path.basename(file_path),
+                    relative_path=get_relative_path(self.base_path, file_path),
+                    absolute_path=absolute_path,
+                    is_dir=True,
+                )
             stat = os.stat(absolute_path, follow_symlinks=False)
             return FileSystemItem(
                 filename=os.path.basename(file_path),
-                path=get_relative_path(self.base_path, file_path),
+                relative_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),
+                is_dir=False,
                 checksum=str(int(stat.st_mtime)),
                 file_size=stat.st_size,
             )
index 2dc37e9bec200497a0ad3136af2b024b7015433f..e7a66201dd311ff3545b351c49f70a465b8118cd 100644 (file)
@@ -16,20 +16,19 @@ class FileSystemItem:
     """Representation of an item (file or directory) on the filesystem.
 
     - filename: Name (not path) of the file (or directory).
-    - path: Relative path to the item on this filesystem provider.
+    - relative_path: Relative path to the item on this filesystem provider.
     - absolute_path: Absolute path to this item.
-    - is_file: Boolean if item is file (not directory or symlink).
+    - parent_path: Absolute path to the parent directory.
     - is_dir: Boolean if item is directory (not file).
-    - checksum: Checksum for this path (usually last modified time).
+    - checksum: Checksum for this path (usually last modified time) None for dir.
     - file_size : File size in number of bytes or None if unknown (or not a file).
     """
 
     filename: str
-    path: str
+    relative_path: str
     absolute_path: str
-    is_file: bool
     is_dir: bool
-    checksum: str
+    checksum: str | None = None
     file_size: int | None = None
 
     @property
@@ -46,6 +45,43 @@ class FileSystemItem:
         """Return file name (without extension)."""
         return self.filename.rsplit(".", 1)[0]
 
+    @property
+    def parent_path(self) -> str:
+        """Return parent path of this item."""
+        return os.path.dirname(self.absolute_path)
+
+    @property
+    def parent_name(self) -> str:
+        """Return parent name of this item."""
+        return os.path.basename(self.parent_path)
+
+    @property
+    def relative_parent_path(self) -> str:
+        """Return relative parent path of this item."""
+        return os.path.dirname(self.relative_path)
+
+    @classmethod
+    def from_dir_entry(cls, entry: os.DirEntry, base_path: str) -> FileSystemItem:
+        """Create FileSystemItem from os.DirEntry. NOT Async friendly."""
+        if entry.is_dir(follow_symlinks=False):
+            return cls(
+                filename=entry.name,
+                relative_path=get_relative_path(base_path, entry.path),
+                absolute_path=entry.path,
+                is_dir=True,
+                checksum=None,
+                file_size=None,
+            )
+        stat = entry.stat(follow_symlinks=False)
+        return cls(
+            filename=entry.name,
+            relative_path=get_relative_path(base_path, entry.path),
+            absolute_path=entry.path,
+            is_dir=False,
+            checksum=str(int(stat.st_mtime)),
+            file_size=stat.st_size,
+        )
+
 
 def get_artist_dir(
     artist_name: str,
@@ -181,25 +217,13 @@ def sorted_scandir(base_path: str, sub_path: str, sort: bool = False) -> list[Fi
         """Sort key for natural sorting."""
         return tuple(int(s) if s.isdigit() else s for s in re.split(r"(\d+)", name))
 
-    def create_item(entry: os.DirEntry) -> FileSystemItem:
-        """Create FileSystemItem from os.DirEntry."""
-        absolute_path = get_absolute_path(base_path, entry.path)
-        stat = entry.stat(follow_symlinks=False)
-        return FileSystemItem(
-            filename=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,
-        )
-
     items = [
-        create_item(x)
+        FileSystemItem.from_dir_entry(x, base_path)
         for x in os.scandir(sub_path)
         # filter out invalid dirs and hidden files
-        if x.name not in IGNORE_DIRS and not x.name.startswith(".")
+        if (x.is_dir(follow_symlinks=False) or x.is_file(follow_symlinks=False))
+        and x.name not in IGNORE_DIRS
+        and not x.name.startswith(".")
     ]
     if sort:
         return sorted(
index 184a11e5d79a2201b6630105f555ddb4636f7e9c..056f665ee2e9eef1572a5b6d572ab6039eb33499 100644 (file)
@@ -13,17 +13,8 @@ from enum import IntFlag
 from typing import TYPE_CHECKING, Any
 
 from hass_client.exceptions import FailedCommand
-from music_assistant_models.config_entries import (
-    ConfigEntry,
-    ConfigValueOption,
-    ConfigValueType,
-)
-from music_assistant_models.enums import (
-    ConfigEntryType,
-    PlayerFeature,
-    PlayerState,
-    PlayerType,
-)
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
+from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType
 from music_assistant_models.errors import SetupFailedError
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
@@ -40,7 +31,7 @@ from music_assistant.constants import (
     create_sample_rates_config_entry,
 )
 from music_assistant.helpers.datetime import from_iso_string
-from music_assistant.helpers.tags import parse_tags
+from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.models.player_provider import PlayerProvider
 from music_assistant.providers.hass import DOMAIN as HASS_DOMAIN
 
@@ -342,7 +333,7 @@ class HomeAssistantPlayers(PlayerProvider):
         )
         # Wait until the announcement is finished playing
         # This is helpful for people who want to play announcements in a sequence
-        media_info = await parse_tags(announcement.uri)
+        media_info = await async_parse_tags(announcement.uri)
         duration = media_info.duration or 5
         await asyncio.sleep(duration)
         self.logger.debug(
index 0629d342f7fcd37fc6d4ca3f758dd8060f389df3..f6967b71e46b920f77933b6aaa1c67a4ae59668b 100644 (file)
@@ -55,7 +55,7 @@ from plexapi.server import PlexServer
 
 from music_assistant.constants import UNKNOWN_ARTIST
 from music_assistant.helpers.auth import AuthenticationHelper
-from music_assistant.helpers.tags import parse_tags
+from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.helpers.util import parse_title_and_version
 from music_assistant.models.music_provider import MusicProvider
 from music_assistant.providers.plex.helpers import discover_local_servers, get_libraries
@@ -928,7 +928,7 @@ class PlexProvider(MusicProvider):
 
         else:
             url = plex_track.getStreamURL()
-            media_info = await parse_tags(url)
+            media_info = await async_parse_tags(url)
             stream_details.path = url
             stream_details.audio_format.channels = media_info.channels
             stream_details.audio_format.content_type = ContentType.try_parse(media_info.format)
index 5b59b16a98bb18717828efa74e8e0e83da173bba..ec2f83d8e55af357c8c17ca1bed3087cbb27c4c1 100644 (file)
@@ -30,7 +30,7 @@ from music_assistant.constants import (
     VERBOSE_LOG_LEVEL,
     create_sample_rates_config_entry,
 )
-from music_assistant.helpers.tags import parse_tags
+from music_assistant.helpers.tags import async_parse_tags
 from music_assistant.models.player_provider import PlayerProvider
 
 from .const import CONF_AIRPLAY_MODE
@@ -320,7 +320,7 @@ class SonosPlayerProvider(PlayerProvider):
         # Wait until the announcement is finished playing
         # This is helpful for people who want to play announcements in a sequence
         # yeah we can also setup a subscription on the sonos player for this, but this is easier
-        media_info = await parse_tags(announcement.uri)
+        media_info = await async_parse_tags(announcement.uri)
         duration = media_info.duration or 10
         await asyncio.sleep(duration)
 
index e1e9948bdd0baddb40d38a7e5dac9c26f6e0fc55..2e0701084b7467befe0396569190d9eb1a432313 100644 (file)
@@ -47,7 +47,7 @@ from tidalapi import Track as TidalTrack
 from tidalapi import exceptions as tidal_exceptions
 
 from music_assistant.helpers.auth import AuthenticationHelper
-from music_assistant.helpers.tags import AudioTags, parse_tags
+from music_assistant.helpers.tags import AudioTags, async_parse_tags
 from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
 from music_assistant.models.music_provider import MusicProvider
 
@@ -939,7 +939,7 @@ class TidalProvider(MusicProvider):
             media_info = AudioTags.parse(cached_info)
         else:
             # parse info with ffprobe (and store in cache)
-            media_info = await parse_tags(url)
+            media_info = await async_parse_tags(url)
             await self.mass.cache.set(
                 item_id,
                 media_info.raw,
index c8d35453c708dcd2200eba5288fd8431a81d964b..d04a33f3ef18c8a7c0ed1cb36df2e440932d4cab 100644 (file)
@@ -12,7 +12,7 @@ FILE_1 = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle.mp3"))
 async def test_parse_metadata_from_id3tags() -> None:
     """Test parsing of parsing metadata from ID3 tags."""
     filename = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle.mp3"))
-    _tags = await tags.parse_tags(filename)
+    _tags = await tags.async_parse_tags(filename)
     assert _tags.album == "MyAlbum"
     assert _tags.title == "MyTitle"
     assert _tags.duration == 1.032
@@ -46,7 +46,7 @@ async def test_parse_metadata_from_id3tags() -> None:
 async def test_parse_metadata_from_filename() -> None:
     """Test parsing of parsing metadata from filename."""
     filename = str(RESOURCES_DIR.joinpath("MyArtist - MyTitle without Tags.mp3"))
-    _tags = await tags.parse_tags(filename)
+    _tags = await tags.async_parse_tags(filename)
     assert _tags.album is None
     assert _tags.title == "MyTitle without Tags"
     assert _tags.duration == 1.032
@@ -62,7 +62,7 @@ async def test_parse_metadata_from_filename() -> None:
 async def test_parse_metadata_from_invalid_filename() -> None:
     """Test parsing of parsing metadata from (invalid) filename."""
     filename = str(RESOURCES_DIR.joinpath("test.mp3"))
-    _tags = await tags.parse_tags(filename)
+    _tags = await tags.async_parse_tags(filename)
     assert _tags.album is None
     assert _tags.title == "test"
     assert _tags.duration == 1.032