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}")