diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py index bfcf1a54f..72dc2276f 100644 --- a/music_assistant/controllers/players.py +++ b/music_assistant/controllers/players.py @@ -44,7 +44,7 @@ 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 @@ async def _play_announcement( 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( diff --git a/music_assistant/helpers/tags.py b/music_assistant/helpers/tags.py index 55def74b6..60aa3bfa1 100644 --- a/music_assistant/helpers/tags.py +++ b/music_assistant/helpers/tags.py @@ -6,6 +6,7 @@ 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 @@ def get(self, key: str, default=None) -> Any: 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: diff --git a/music_assistant/providers/builtin/__init__.py b/music_assistant/providers/builtin/__init__.py index ee1b57be5..25aa0c622 100644 --- a/music_assistant/providers/builtin/__init__.py +++ b/music_assistant/providers/builtin/__init__.py @@ -40,7 +40,7 @@ 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 @@ async def _get_media_info(self, url: str, force_refresh: bool = False) -> AudioT 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( diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index c10b20f30..fa746715f 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -7,17 +7,15 @@ 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 @@ 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.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 @@ ) 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 @@ 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 @@ async def browse(self, path: str) -> list[MediaItemType | ItemMapping]: 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 @@ async def browse(self, path: str) -> list[MediaItemType | ItemMapping]: 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 @@ async def browse(self, path: str) -> list[MediaItemType | ItemMapping]: 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 @@ async def browse(self, path: str) -> list[MediaItemType | ItemMapping]: 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 @@ async def browse(self, path: str) -> list[MediaItemType | ItemMapping]: 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 @@ async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: # 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 @@ async def sync_library(self, media_types: tuple[MediaType, ...]) -> None: # 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 @@ async def get_album(self, prov_album_id: str) -> Album: 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 @@ async def get_track(self, prov_track_id: str) -> Track: 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 @@ async def get_playlist(self, prov_playlist_id: str) -> Playlist: 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 @@ async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | N # 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 @@ async def get_stream_details( 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 @@ async def resolve_image(self, path: str) -> str | bytes: 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 @@ async def _parse_track( # 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 @@ async def _parse_track( [ MediaItemImage( type=ImageType.THUMB, - path=file_item.path, + path=file_item.relative_path, provider=self.instance_id, remotely_accessible=False, ) @@ -793,11 +835,13 @@ async def _parse_track( # 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 @@ async def _parse_album(self, track_path: str, track_tags: AudioTags) -> Album: 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 @@ async def _get_local_images(self, folder: str) -> UniqueList[MediaItemImage]: 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 @@ async def _get_local_images(self, folder: str) -> UniqueList[MediaItemImage]: images.append( MediaItemImage( type=ImageType.THUMB, - path=item.path, + path=item.relative_path, provider=self.instance_id, remotely_accessible=False, ) @@ -1083,33 +1128,6 @@ async def check_write_access(self) -> None: 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 @@ async def resolve( 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, ) diff --git a/music_assistant/providers/filesystem_local/helpers.py b/music_assistant/providers/filesystem_local/helpers.py index 2dc37e9be..e7a66201d 100644 --- a/music_assistant/providers/filesystem_local/helpers.py +++ b/music_assistant/providers/filesystem_local/helpers.py @@ -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 @@ def name(self) -> str: """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 nat_key(name: str) -> tuple[int | str, ...]: """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( diff --git a/music_assistant/providers/hass_players/__init__.py b/music_assistant/providers/hass_players/__init__.py index 184a11e5d..056f665ee 100644 --- a/music_assistant/providers/hass_players/__init__.py +++ b/music_assistant/providers/hass_players/__init__.py @@ -13,17 +13,8 @@ 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 @@ 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 @@ async def play_announcement( ) # 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( diff --git a/music_assistant/providers/plex/__init__.py b/music_assistant/providers/plex/__init__.py index 0629d342f..f6967b71e 100644 --- a/music_assistant/providers/plex/__init__.py +++ b/music_assistant/providers/plex/__init__.py @@ -55,7 +55,7 @@ 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 @@ async def get_stream_details( 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) diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index 5b59b16a9..ec2f83d8e 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -30,7 +30,7 @@ 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 @@ async def play_announcement( # 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) diff --git a/music_assistant/providers/tidal/__init__.py b/music_assistant/providers/tidal/__init__.py index e1e9948bd..2e0701084 100644 --- a/music_assistant/providers/tidal/__init__.py +++ b/music_assistant/providers/tidal/__init__.py @@ -47,7 +47,7 @@ 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 @@ async def _get_media_info( 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, diff --git a/tests/core/test_tags.py b/tests/core/test_tags.py index c8d35453c..d04a33f3e 100644 --- a/tests/core/test_tags.py +++ b/tests/core/test_tags.py @@ -12,7 +12,7 @@ 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