diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py
index 557ce34835..c4252cefb8 100644
--- a/music_assistant/providers/filesystem_local/__init__.py
+++ b/music_assistant/providers/filesystem_local/__init__.py
@@ -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
@@ -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:
@@ -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:
@@ -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,
@@ -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
@@ -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."""
@@ -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:
@@ -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)
diff --git a/music_assistant/providers/webdav/__init__.py b/music_assistant/providers/webdav/__init__.py
new file mode 100644
index 0000000000..f3c98799ea
--- /dev/null
+++ b/music_assistant/providers/webdav/__init__.py
@@ -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",
+ ),
+ )
diff --git a/music_assistant/providers/webdav/constants.py b/music_assistant/providers/webdav/constants.py
new file mode 100644
index 0000000000..9487ef23eb
--- /dev/null
+++ b/music_assistant/providers/webdav/constants.py
@@ -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
diff --git a/music_assistant/providers/webdav/helpers.py b/music_assistant/providers/webdav/helpers.py
new file mode 100644
index 0000000000..22b260bc0d
--- /dev/null
+++ b/music_assistant/providers/webdav/helpers.py
@@ -0,0 +1,220 @@
+"""WebDAV helper functions for Music Assistant."""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+from dataclasses import dataclass
+from urllib.parse import unquote, urljoin
+
+import aiohttp
+from defusedxml import ElementTree
+from music_assistant_models.errors import LoginFailed, SetupFailedError
+
+LOGGER = logging.getLogger(__name__)
+
+DAV_NAMESPACE = {"d": "DAV:"}
+
+
+@dataclass
+class WebDAVItem:
+ """Representation of a WebDAV resource."""
+
+ href: str
+ name: str
+ is_dir: bool
+ size: int | None = None
+ last_modified: str | None = None
+
+ @property
+ def ext(self) -> str | None:
+ """Return file extension."""
+ if self.is_dir:
+ return None
+ try:
+ return self.name.rsplit(".", 1)[1].lower()
+ except IndexError:
+ return None
+
+
+async def webdav_propfind(
+ session: aiohttp.ClientSession,
+ url: str,
+ depth: int = 1,
+ timeout: int = 30,
+ auth: aiohttp.BasicAuth | None = None,
+) -> list[WebDAVItem]:
+ """
+ Execute a PROPFIND request on a WebDAV resource.
+
+ Args:
+ session: Active HTTP session with auth configured
+ url: WebDAV URL to query
+ depth: Depth level (0=properties only, 1=immediate children)
+ timeout: Request timeout in seconds
+
+ Returns:
+ List of WebDAVItem objects
+
+ Raises:
+ LoginFailed: Authentication failed
+ SetupFailedError: Connection or other setup issues
+ """
+ headers = {
+ "Depth": str(depth),
+ "Content-Type": "application/xml; charset=utf-8",
+ }
+
+ body = """
+
+
+
+
+
+
+
+"""
+
+ try:
+ async with session.request(
+ "PROPFIND",
+ url,
+ headers=headers,
+ data=body,
+ auth=auth,
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ ) as resp:
+ if resp.status == 401:
+ raise LoginFailed(f"Authentication failed for WebDAV server: {url}")
+ if resp.status == 403:
+ raise LoginFailed(f"Access forbidden for WebDAV server: {url}")
+ if resp.status == 404:
+ LOGGER.debug("WebDAV resource not found: %s", url)
+ return []
+ elif resp.status >= 400:
+ raise SetupFailedError(
+ f"WebDAV PROPFIND failed with status {resp.status} for {url}"
+ )
+
+ response_text = await resp.text()
+ return _parse_propfind_response(response_text, url)
+
+ except TimeoutError as err:
+ raise SetupFailedError(f"WebDAV connection timeout: {url}") from err
+ except aiohttp.ClientError as err:
+ raise SetupFailedError(f"WebDAV connection error: {err}") from err
+
+
+def _parse_propfind_response(response_text: str, base_url: str) -> list[WebDAVItem]:
+ """Parse WebDAV PROPFIND XML response."""
+ try:
+ root = ElementTree.fromstring(response_text)
+ except ElementTree.ParseError as err:
+ LOGGER.warning("Failed to parse WebDAV PROPFIND response: %s", err)
+ return []
+
+ items: list[WebDAVItem] = []
+
+ for response_elem in root.findall("d:response", DAV_NAMESPACE):
+ href_elem = response_elem.find("d:href", DAV_NAMESPACE)
+ if href_elem is None or not href_elem.text:
+ continue
+
+ href = unquote(href_elem.text.rstrip("/"))
+
+ # Skip the base directory itself
+ if href.rstrip("/") == base_url.rstrip("/"):
+ continue
+
+ # Extract properties
+ propstat = response_elem.find("d:propstat", DAV_NAMESPACE)
+ if propstat is None:
+ continue
+
+ prop = propstat.find("d:prop", DAV_NAMESPACE)
+ if prop is None:
+ continue
+
+ # Check if it's a directory
+ resourcetype = prop.find("d:resourcetype", DAV_NAMESPACE)
+ is_collection = (
+ resourcetype is not None
+ and resourcetype.find("d:collection", DAV_NAMESPACE) is not None
+ )
+
+ # Get size
+ size = None
+ if not is_collection:
+ contentlength = prop.find("d:getcontentlength", DAV_NAMESPACE)
+ if contentlength is not None and contentlength.text:
+ with contextlib.suppress(ValueError):
+ size = int(contentlength.text)
+
+ # Get last modified
+ lastmodified = prop.find("d:getlastmodified", DAV_NAMESPACE)
+ last_modified = lastmodified.text if lastmodified is not None else None
+
+ # Get display name or extract from href
+ displayname = prop.find("d:displayname", DAV_NAMESPACE)
+ if displayname is not None and displayname.text:
+ name = displayname.text
+ else:
+ name = href.split("/")[-1] or href.split("/")[-2]
+
+ items.append(
+ WebDAVItem(
+ href=href,
+ name=name,
+ is_dir=is_collection,
+ size=size,
+ last_modified=last_modified,
+ )
+ )
+
+ return items
+
+
+async def webdav_test_connection(
+ base_url: str,
+ username: str | None,
+ password: str | None,
+ verify_ssl: bool,
+ timeout: int = 10,
+) -> None:
+ """Test WebDAV connection and authentication."""
+ auth = aiohttp.BasicAuth(username, password) if username and password else None
+ connector = aiohttp.TCPConnector(ssl=verify_ssl)
+
+ async with aiohttp.ClientSession(
+ auth=auth,
+ connector=connector,
+ timeout=aiohttp.ClientTimeout(total=timeout),
+ ) as session:
+ try:
+ items = await webdav_propfind(session, base_url, depth=0)
+ if not items and base_url.rstrip("/") != base_url:
+ # Try without trailing slash
+ items = await webdav_propfind(session, base_url.rstrip("/"), depth=0)
+ # Success if we got any response
+ except aiohttp.ClientResponseError as err:
+ if err.status == 401:
+ raise LoginFailed("Invalid username or password")
+ if err.status == 404:
+ raise SetupFailedError(f"WebDAV path not found: {base_url}")
+ raise SetupFailedError(f"WebDAV connection failed: {err}")
+ except aiohttp.ClientError as err:
+ raise SetupFailedError(f"Cannot connect to WebDAV server: {err}")
+
+
+def normalize_webdav_url(url: str) -> str:
+ """Normalize WebDAV URL by ensuring it ends with a slash."""
+ if not url.endswith("/"):
+ return f"{url}/"
+ return url
+
+
+def build_webdav_url(base_url: str, path: str) -> str:
+ """Build a WebDAV URL by joining base URL with path."""
+ normalized_base = normalize_webdav_url(base_url)
+ path = path.removeprefix("/")
+ return urljoin(normalized_base, path)
diff --git a/music_assistant/providers/webdav/icon.svg b/music_assistant/providers/webdav/icon.svg
new file mode 100644
index 0000000000..ce09e50904
--- /dev/null
+++ b/music_assistant/providers/webdav/icon.svg
@@ -0,0 +1,46 @@
+
+
+
+
diff --git a/music_assistant/providers/webdav/icon_monochrome.svg b/music_assistant/providers/webdav/icon_monochrome.svg
new file mode 100644
index 0000000000..ce09e50904
--- /dev/null
+++ b/music_assistant/providers/webdav/icon_monochrome.svg
@@ -0,0 +1,46 @@
+
+
+
+
diff --git a/music_assistant/providers/webdav/manifest.json b/music_assistant/providers/webdav/manifest.json
new file mode 100644
index 0000000000..1660384781
--- /dev/null
+++ b/music_assistant/providers/webdav/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "webdav",
+ "name": "WebDav Provider",
+ "description": "Stream music and audiobooks stored on any WebDAV-compatible server",
+ "documentation": "https://music-assistant.io/music-providers/webdav/",
+ "type": "music",
+ "requirements": [],
+ "codeowners": "@ozgav",
+ "multi_instance": true,
+ "stage": "beta"
+}
diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py
new file mode 100644
index 0000000000..772abc299f
--- /dev/null
+++ b/music_assistant/providers/webdav/provider.py
@@ -0,0 +1,349 @@
+"""WebDAV File System Provider for Music Assistant."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import PurePosixPath
+from typing import TYPE_CHECKING, cast
+from urllib.parse import quote, unquote, urlparse, urlunparse
+
+import aiohttp
+from music_assistant_models.errors import (
+ LoginFailed,
+ MediaNotFoundError,
+ ProviderUnavailableError,
+ SetupFailedError,
+)
+
+from music_assistant.constants import (
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ DB_TABLE_PROVIDER_MAPPINGS,
+ VERBOSE_LOG_LEVEL,
+)
+from music_assistant.providers.filesystem_local import LocalFileSystemProvider
+from music_assistant.providers.filesystem_local.helpers import FileSystemItem
+
+from .constants import CONF_CONTENT_TYPE, CONF_URL, CONF_VERIFY_SSL
+from .helpers import build_webdav_url, webdav_propfind, webdav_test_connection
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import ProviderConfig
+ from music_assistant_models.enums import MediaType
+ from music_assistant_models.provider import ProviderManifest
+
+ from music_assistant.mass import MusicAssistant
+
+
+class WebDAVFileSystemProvider(LocalFileSystemProvider):
+ """WebDAV File System Provider for Music Assistant."""
+
+ def __init__(
+ self,
+ mass: MusicAssistant,
+ manifest: ProviderManifest,
+ config: ProviderConfig,
+ ) -> None:
+ """Initialize WebDAV FileSystem Provider."""
+ base_url = cast("str", config.get_value(CONF_URL)).rstrip("/")
+ super().__init__(mass, manifest, config, base_url)
+ self.base_url = base_url
+ self.username = cast("str | None", config.get_value(CONF_USERNAME))
+ self.password = cast("str | None", config.get_value(CONF_PASSWORD))
+ self.verify_ssl = cast("bool", config.get_value(CONF_VERIFY_SSL))
+ self.media_content_type = cast("str", config.get_value(CONF_CONTENT_TYPE))
+
+ @property
+ def instance_name_postfix(self) -> str | None:
+ """Return a (default) instance name postfix for this provider instance."""
+ try:
+ parsed = urlparse(self.base_url)
+ if parsed.path and parsed.path != "/":
+ return PurePosixPath(parsed.path).name
+ return parsed.netloc
+ except (ValueError, TypeError):
+ return "Invalid URL"
+
+ @property
+ def _auth(self) -> aiohttp.BasicAuth | None:
+ """Get BasicAuth for WebDAV requests."""
+ if self.username:
+ return aiohttp.BasicAuth(self.username, self.password or "")
+ return None
+
+ async def handle_async_init(self) -> None:
+ """Handle async initialization of the provider."""
+ try:
+ await webdav_test_connection(
+ self.base_url,
+ self.username,
+ self.password,
+ self.verify_ssl,
+ timeout=10,
+ )
+ except (LoginFailed, SetupFailedError):
+ raise
+ except Exception as err:
+ raise SetupFailedError(f"WebDAV connection failed: {err}") from err
+
+ self.write_access = False
+
+ def _build_authenticated_url(self, file_path: str) -> str:
+ """Build authenticated WebDAV URL with properly encoded credentials."""
+ webdav_url = build_webdav_url(self.base_url, file_path)
+ parsed = urlparse(webdav_url)
+
+ if self.username and self.password:
+ encoded_username = quote(self.username, safe="")
+ encoded_password = quote(self.password, safe="")
+ netloc = f"{encoded_username}:{encoded_password}@{parsed.netloc}"
+ return urlunparse(
+ (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment)
+ )
+
+ return webdav_url
+
+ async def _exists_impl(self, file_path: str) -> bool:
+ """Check if WebDAV resource exists."""
+ if not file_path:
+ return False
+ # Handle case where absolute URL is passed
+ if file_path.startswith("http"):
+ parsed = urlparse(file_path)
+ base_parsed = urlparse(self.base_url)
+ file_path = parsed.path[len(base_parsed.path) :].strip("/")
+ try:
+ webdav_url = build_webdav_url(self.base_url, file_path)
+ session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl
+ items = await webdav_propfind(session, webdav_url, depth=0, auth=self._auth)
+ return len(items) > 0 or webdav_url.rstrip("/") == self.base_url.rstrip("/")
+ except (LoginFailed, SetupFailedError):
+ raise
+ except aiohttp.ClientError as err:
+ self.logger.debug(f"WebDAV client error during exists check for {file_path}: {err}")
+ return False
+ except Exception as err:
+ self.logger.debug(f"WebDAV exists check failed for {file_path}: {err}")
+ return False
+
+ async def _resolve_impl(self, file_path: str) -> FileSystemItem:
+ """Resolve WebDAV path to FileSystemItem."""
+ webdav_url = build_webdav_url(self.base_url, file_path)
+ session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl
+
+ try:
+ items = await webdav_propfind(session, webdav_url, depth=0, auth=self._auth)
+ if not items:
+ if webdav_url.rstrip("/") == self.base_url.rstrip("/"):
+ return FileSystemItem(
+ filename="",
+ relative_path="",
+ absolute_path=self._build_authenticated_url(file_path),
+ is_dir=True,
+ )
+ raise MediaNotFoundError(f"WebDAV resource not found: {file_path}")
+
+ webdav_item = items[0]
+
+ return FileSystemItem(
+ filename=PurePosixPath(file_path).name or webdav_item.name,
+ relative_path=file_path,
+ absolute_path=self._build_authenticated_url(file_path),
+ is_dir=webdav_item.is_dir,
+ checksum=webdav_item.last_modified or "unknown",
+ file_size=webdav_item.size,
+ )
+
+ except MediaNotFoundError:
+ raise
+ except (LoginFailed, SetupFailedError):
+ raise
+ except aiohttp.ClientError as err:
+ raise MediaNotFoundError(f"Failed to resolve WebDAV path {file_path}: {err}") from err
+
+ async def _scandir_impl(self, path: str) -> list[FileSystemItem]:
+ """List WebDAV directory contents."""
+ # Handle case where absolute URL is passed (from parent's code)
+ if path.startswith("http"):
+ parsed = urlparse(path)
+ base_parsed = urlparse(self.base_url)
+ path = parsed.path[len(base_parsed.path) :].strip("/")
+ self.logger.debug(f"Converted absolute URL to relative path: {path}")
+
+ self.logger.debug(f"Scanning WebDAV path: {path}")
+ webdav_url = build_webdav_url(self.base_url, path)
+ session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl
+
+ try:
+ webdav_items = await webdav_propfind(session, webdav_url, depth=1, auth=self._auth)
+ self.logger.debug(f"WebDAV returned {len(webdav_items)} items for {path}") # ADD THIS
+
+ filesystem_items: list[FileSystemItem] = []
+
+ # Parse base path component for comparison
+ base_parsed = urlparse(self.base_url)
+ base_path = base_parsed.path.rstrip("/")
+
+ for webdav_item in webdav_items:
+ self.logger.debug(
+ f"Processing item: name={webdav_item.name}, "
+ f"href={webdav_item.href[:100]}, is_dir={webdav_item.is_dir}"
+ )
+
+ if "#recycle" in webdav_item.name.lower():
+ continue
+ decoded_name = unquote(webdav_item.name)
+ decoded_href = unquote(webdav_item.href)
+
+ # If href is a full URL, extract just the path component
+ if decoded_href.startswith("http"):
+ href_parsed = urlparse(decoded_href)
+ href_path = href_parsed.path
+ else:
+ href_path = decoded_href
+
+ # Skip the directory itself
+ current_path = urlparse(webdav_url).path.rstrip("/")
+ if href_path.rstrip("/") == current_path:
+ self.logger.debug(f"Skipping directory itself: {href_path}")
+
+ continue
+ self.logger.debug(f"After skip check, processing: {webdav_item.name}")
+
+ # Calculate relative path by stripping base path
+ if href_path.startswith((base_path + "/", base_path)):
+ relative_path = href_path[len(base_path) :].strip("/")
+ else:
+ # Fallback: construct from current path + name
+ relative_path = (
+ str(PurePosixPath(path) / decoded_name) if path else decoded_name
+ )
+ self.logger.debug(
+ f"Item: {decoded_name}, href: {decoded_href[:80]}, "
+ f"relative_path: {relative_path}"
+ )
+ self.logger.debug(
+ f"Calculated relative_path: '{relative_path}' for {webdav_item.name}"
+ )
+
+ decoded_name = unquote(webdav_item.name)
+
+ filesystem_items.append(
+ FileSystemItem(
+ filename=decoded_name,
+ relative_path=relative_path,
+ absolute_path=self._build_authenticated_url(relative_path),
+ is_dir=webdav_item.is_dir,
+ checksum=webdav_item.last_modified or "unknown",
+ file_size=webdav_item.size,
+ )
+ )
+ self.logger.debug(f"Added to filesystem_items: {decoded_name}")
+ self.logger.debug(
+ f"Parsed {len(filesystem_items)} filesystem items for {path}"
+ ) # ADD THIS
+
+ return filesystem_items
+
+ except (LoginFailed, SetupFailedError, ProviderUnavailableError):
+ raise
+ except aiohttp.ClientError as err:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ f"WebDAV client error listing directory {path}: {err}",
+ )
+ raise ProviderUnavailableError(f"WebDAV server connection failed: {err}") from err
+ except Exception as err:
+ self.logger.exception(f"Failed to list WebDAV directory {path}: {err}")
+
+ return []
+
+ async def resolve_image(self, path: str) -> str | bytes:
+ """Resolve image path to actual image data or URL."""
+ webdav_url = build_webdav_url(self.base_url, path)
+ session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl
+
+ async with session.get(webdav_url, auth=self._auth) as resp:
+ if resp.status != 200:
+ raise MediaNotFoundError(f"Image not found: {path}")
+ return await resp.read()
+
+ async def sync_library(self, media_type: MediaType, import_as_favorite: bool = False) -> None:
+ """Run library sync for WebDAV provider."""
+ assert self.mass.music.database
+
+ if self.sync_running:
+ self.logger.warning(f"Library sync already running for {self.name}")
+ return
+
+ self.logger.info(f"Started library sync for WebDAV provider {self.name}")
+ self.sync_running = True
+
+ try:
+ file_checksums: dict[str, str] = {}
+ query = (
+ f"SELECT provider_item_id, details FROM {DB_TABLE_PROVIDER_MAPPINGS} "
+ f"WHERE provider_instance = '{self.instance_id}' "
+ "AND media_type in ('track', 'playlist', 'audiobook', 'podcast_episode')"
+ )
+ for db_row in await self.mass.music.database.get_rows_from_query(query, limit=0):
+ file_checksums[db_row["provider_item_id"]] = str(db_row["details"])
+
+ cur_filenames: set[str] = set()
+ prev_filenames: set[str] = set(file_checksums.keys())
+
+ await self._scan_recursive("", cur_filenames, file_checksums, import_as_favorite)
+
+ deleted_files = prev_filenames - cur_filenames
+ await self._process_deletions(deleted_files)
+ await self._process_orphaned_albums_and_artists()
+
+ except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err:
+ self.logger.error(f"WebDAV library sync failed due to provider error: {err}")
+ raise
+ except aiohttp.ClientError as err:
+ self.logger.error(f"WebDAV library sync failed due to connection error: {err}")
+ raise ProviderUnavailableError(f"WebDAV server connection failed: {err}") from err
+ except Exception as err:
+ self.logger.error(f"WebDAV library sync failed with unexpected error: {err}")
+ raise SetupFailedError(f"WebDAV library sync failed: {err}") from err
+ finally:
+ self.sync_running = False
+ self.logger.info(f"Completed library sync for WebDAV provider {self.name}")
+
+ async def _scan_recursive(
+ self,
+ path: str,
+ cur_filenames: set[str],
+ file_checksums: dict[str, str],
+ import_as_favorite: bool,
+ ) -> None:
+ """Recursively scan WebDAV directory."""
+ try:
+ items = await self._scandir_impl(path)
+
+ # Separate directories and files
+ dirs = [item for item in items if item.is_dir]
+ files = [item for item in items if not item.is_dir]
+
+ # Process files in executor (blocking operation)
+ for item in files:
+ prev_checksum = file_checksums.get(item.relative_path)
+ # Wrap _process_item in executor since it's blocking
+ if await asyncio.to_thread(
+ self._process_item, item, prev_checksum, import_as_favorite
+ ):
+ cur_filenames.add(item.relative_path)
+
+ # Recurse into directories
+ for dir_item in dirs:
+ await self._scan_recursive(
+ dir_item.relative_path, cur_filenames, file_checksums, import_as_favorite
+ )
+
+ except (LoginFailed, SetupFailedError, ProviderUnavailableError):
+ raise
+ except aiohttp.ClientError as err:
+ self.logger.warning(f"WebDAV client error scanning path {path}: {err}")
+ except Exception as err:
+ self.logger.warning(f"Failed to scan WebDAV path {path}: {err}")