Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 45 additions & 41 deletions music_assistant/providers/filesystem_local/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,7 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow
item_path = path.split("://", 1)[1]
if not item_path:
item_path = ""
abs_path = self.get_absolute_path(item_path)
for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True):
for item in await self._scandir_impl(item_path):
if not item.is_dir and ("." not in item.filename or not item.ext):
# skip system files and files without extension
continue
Expand Down Expand Up @@ -757,7 +756,7 @@ async def _process_podcast_episode(item: FileSystemItem) -> None:
episodes.append(episode)

async with TaskManager(self.mass, 25) as tm:
for item in await asyncio.to_thread(sorted_scandir, self.base_path, prov_podcast_id):
for item in await self._scandir_impl(prov_podcast_id):
if "." not in item.relative_path or item.is_dir:
continue
if item.ext not in PODCAST_EPISODE_EXTENSIONS:
Expand Down Expand Up @@ -1181,8 +1180,7 @@ async def _parse_audiobook(self, file_item: FileSystemItem, tags: AudioTags) ->
# try to fetch additional metadata from the folder
if not audio_book.image or not audio_book.metadata.description:
# try to get an image by traversing files in the same folder
abs_path = self.get_absolute_path(file_item.parent_path)
for _item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path):
for _item in await self._scandir_impl(file_item.parent_path):
if "." not in _item.relative_path or _item.is_dir:
continue
if _item.ext in IMAGE_EXTENSIONS and not audio_book.image:
Expand Down Expand Up @@ -1256,6 +1254,7 @@ async def _parse_podcast_episode(
name=podcast_name,
sort_name=tags.album_sort,
publisher=tags.tags.get("publisher"),
total_episodes=0,
provider_mappings={
ProviderMapping(
item_id=podcast_path,
Expand Down Expand Up @@ -1515,8 +1514,7 @@ async def _get_local_images(
if extra_thumb_names is None:
extra_thumb_names = ()
images: UniqueList[MediaItemImage] = UniqueList()
abs_path = self.get_absolute_path(folder)
folder_files = await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=False)
folder_files = await self._scandir_impl(folder)
for item in folder_files:
if "." not in item.relative_path or item.is_dir or not item.ext:
continue
Expand Down Expand Up @@ -1573,40 +1571,13 @@ async def check_write_access(self) -> None:
except Exception as err:
self.logger.debug("Write access disabled: %s", str(err))

async def resolve(
self,
file_path: str,
) -> FileSystemItem:
async def resolve(self, file_path: str) -> FileSystemItem:
"""Resolve (absolute or relative) path to FileSystemItem."""
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),
relative_path=get_relative_path(self.base_path, file_path),
absolute_path=absolute_path,
is_dir=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)
return await self._resolve_impl(file_path)

async def exists(self, file_path: str) -> bool:
"""Return bool is this FileSystem musicprovider has given file/dir."""
if not file_path:
return False # guard
abs_path = self.get_absolute_path(file_path)
return bool(await exists(abs_path))
return await self._exists_impl(file_path)

def get_absolute_path(self, file_path: str) -> str:
"""Return absolute path for given file path."""
Expand Down Expand Up @@ -1753,10 +1724,7 @@ async def _get_chapters_for_audiobook(
# where each file is a portion/chapter of the audiobook
# try to gather the chapters by traversing files in the same folder
chapter_file_tags: list[AudioTags] = []
abs_path = self.get_absolute_path(audiobook_file_item.parent_path)
for item in await asyncio.to_thread(
sorted_scandir, self.base_path, abs_path, sort=True
):
for item in await self._scandir_impl(audiobook_file_item.parent_path):
if "." not in item.relative_path or item.is_dir:
continue
if item.ext not in AUDIOBOOK_EXTENSIONS:
Expand Down Expand Up @@ -1820,3 +1788,39 @@ async def _get_podcast_metadata(self, podcast_folder: str) -> dict[str, Any]:
category=CACHE_CATEGORY_PODCAST_METADATA,
)
return data

async def _exists_impl(self, file_path: str) -> bool:
"""Check if file/directory exists - override for network storage."""
if not file_path:
return False
abs_path = self.get_absolute_path(file_path)
return bool(await exists(abs_path))

async def _resolve_impl(self, file_path: str) -> FileSystemItem:
"""Resolve path to FileSystemItem - override for network storage."""
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_info = os.stat(absolute_path, follow_symlinks=False)
return FileSystemItem(
filename=os.path.basename(file_path),
relative_path=get_relative_path(self.base_path, file_path),
absolute_path=absolute_path,
is_dir=False,
checksum=str(int(stat_info.st_mtime)),
file_size=stat_info.st_size,
)

return await asyncio.to_thread(_create_item)

async def _scandir_impl(self, path: str) -> list[FileSystemItem]:
"""List directory contents - override for network storage."""
abs_path = self.get_absolute_path(path)
return await asyncio.to_thread(sorted_scandir, self.base_path, abs_path)
108 changes: 108 additions & 0 deletions music_assistant/providers/webdav/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""WebDAV filesystem provider for Music Assistant."""

from __future__ import annotations

from typing import TYPE_CHECKING

from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
from music_assistant_models.enums import ConfigEntryType, ProviderFeature

from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME

from .constants import CONF_CONTENT_TYPE, CONF_URL, CONF_VERIFY_SSL
from .provider import WebDAVFileSystemProvider

if TYPE_CHECKING:
from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
from music_assistant_models.provider import ProviderManifest

from music_assistant.mass import MusicAssistant
from music_assistant.models import ProviderInstanceType


# Supported features
SUPPORTED_FEATURES = {
ProviderFeature.BROWSE,
ProviderFeature.SEARCH,
}


async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
prov = WebDAVFileSystemProvider(mass, manifest, config)
await prov.handle_async_init()
return prov


async def get_config_entries(
mass: MusicAssistant,
instance_id: str | None = None,
action: str | None = None,
values: dict[str, ConfigValueType] | None = None,
) -> tuple[ConfigEntry, ...]:
"""Return Config entries to setup this provider."""
# ruff: noqa: ARG001
return (
ConfigEntry(
key=CONF_CONTENT_TYPE,
type=ConfigEntryType.STRING,
label="Content type",
options=[
ConfigValueOption("Music", "music"),
ConfigValueOption("Audiobooks", "audiobooks"),
ConfigValueOption("Podcasts", "podcasts"),
],
default_value="music",
description="The type of content stored on this WebDAV server",
hidden=instance_id is not None,
),
ConfigEntry(
key=CONF_URL,
type=ConfigEntryType.STRING,
label="WebDAV URL",
required=True,
description="The base URL of your WebDAV server (e.g., https://example.com/webdav)",
),
ConfigEntry(
key=CONF_USERNAME,
type=ConfigEntryType.STRING,
label="Username",
required=False,
description="Username for WebDAV authentication (leave empty for no authentication)",
),
ConfigEntry(
key=CONF_PASSWORD,
type=ConfigEntryType.SECURE_STRING,
label="Password",
required=False,
description="Password for WebDAV authentication",
),
ConfigEntry(
key=CONF_VERIFY_SSL,
type=ConfigEntryType.BOOLEAN,
label="Verify SSL certificate",
default_value=False,
description="Verify SSL certificates when connecting to HTTPS WebDAV servers",
),
ConfigEntry(
key="missing_album_artist_action",
type=ConfigEntryType.STRING,
label="Action when album artist tag is missing",
options=[
ConfigValueOption("Use track artist(s)", "track_artist"),
ConfigValueOption("Use folder name", "folder_name"),
ConfigValueOption("Use 'Various Artists'", "various_artists"),
],
default_value="various_artists",
description="What to do when a track is missing the album artist tag",
),
ConfigEntry(
key="ignore_album_playlists",
type=ConfigEntryType.BOOLEAN,
label="Ignore playlists in album folders",
default_value=True,
description="Ignore playlist files found in album subdirectories",
),
)
10 changes: 10 additions & 0 deletions music_assistant/providers/webdav/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""WebDAV File System Provider constants."""

from typing import Final

# Only WebDAV-specific constants
CONF_URL: Final[str] = "url"
CONF_VERIFY_SSL: Final[str] = "verify_ssl"
CONF_CONTENT_TYPE: Final[str] = "content_type" # This one stays - it's in config
MAX_CONCURRENT_TASKS: Final[int] = 5
WEBDAV_TIMEOUT: Final[int] = 30
Loading