From 392400587804c58c9c10e6d7060101dbdbdda226 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 6 Oct 2025 02:33:49 +1000 Subject: [PATCH 01/10] Add webdav provider --- music_assistant/providers/webdav/__init__.py | 108 ++ music_assistant/providers/webdav/constants.py | 54 + music_assistant/providers/webdav/helpers.py | 226 +++ music_assistant/providers/webdav/icon.svg | 46 + .../providers/webdav/icon_monochrome.svg | 46 + .../providers/webdav/manifest.json | 11 + music_assistant/providers/webdav/parsers.py | 426 ++++++ music_assistant/providers/webdav/provider.py | 1233 +++++++++++++++++ 8 files changed, 2150 insertions(+) create mode 100644 music_assistant/providers/webdav/__init__.py create mode 100644 music_assistant/providers/webdav/constants.py create mode 100644 music_assistant/providers/webdav/helpers.py create mode 100644 music_assistant/providers/webdav/icon.svg create mode 100644 music_assistant/providers/webdav/icon_monochrome.svg create mode 100644 music_assistant/providers/webdav/manifest.json create mode 100644 music_assistant/providers/webdav/parsers.py create mode 100644 music_assistant/providers/webdav/provider.py 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..da193f9b44 --- /dev/null +++ b/music_assistant/providers/webdav/constants.py @@ -0,0 +1,54 @@ +"""Constants for WebDAV filesystem provider.""" + +from __future__ import annotations + +from typing import Final + +# Config keys +CONF_URL = "url" +CONF_VERIFY_SSL = "verify_ssl" +CONF_CONTENT_TYPE = "content_type" + +# File extensions - reuse from filesystem_local patterns +TRACK_EXTENSIONS: Final[tuple[str, ...]] = ( + "mp3", + "flac", + "wav", + "m4a", + "aac", + "ogg", + "wma", + "opus", + "mp4", + "m4p", +) + +PLAYLIST_EXTENSIONS: Final[tuple[str, ...]] = ("m3u", "m3u8", "pls") + +AUDIOBOOK_EXTENSIONS: Final[tuple[str, ...]] = ( + "mp3", + "flac", + "m4a", + "m4b", + "aac", + "ogg", + "opus", + "wav", +) + +PODCAST_EPISODE_EXTENSIONS: Final[tuple[str, ...]] = TRACK_EXTENSIONS + +IMAGE_EXTENSIONS: Final[tuple[str, ...]] = ("jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff") + +SUPPORTED_EXTENSIONS: Final[tuple[str, ...]] = ( + *TRACK_EXTENSIONS, + *PLAYLIST_EXTENSIONS, + *AUDIOBOOK_EXTENSIONS, + *IMAGE_EXTENSIONS, +) + +# WebDAV specific constants +WEBDAV_TIMEOUT: Final[int] = 30 + +# Concurrent processing limit +MAX_CONCURRENT_TASKS: Final[int] = 3 diff --git a/music_assistant/providers/webdav/helpers.py b/music_assistant/providers/webdav/helpers.py new file mode 100644 index 0000000000..c22991fdc5 --- /dev/null +++ b/music_assistant/providers/webdav/helpers.py @@ -0,0 +1,226 @@ +"""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, +) -> 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, + 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( + url: str, + username: str | None = None, + password: str | None = None, + verify_ssl: bool = True, + timeout: int = 10, +) -> None: + """ + Test WebDAV connection and authentication. + + Args: + url: WebDAV server URL + username: Optional username + password: Optional password + verify_ssl: Whether to verify SSL certificates + timeout: Connection timeout + + Raises: + LoginFailed: Authentication or connection failed + SetupFailedError: Server configuration issues + """ + auth = None + if username: + auth = aiohttp.BasicAuth(username, password or "") + + connector = aiohttp.TCPConnector(ssl=verify_ssl) + + try: + async with aiohttp.ClientSession( + auth=auth, + connector=connector, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as session: + # Try a simple PROPFIND to test connectivity + await webdav_propfind(session, url, depth=0, timeout=timeout) + finally: + if connector: + await connector.close() + + +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/parsers.py b/music_assistant/providers/webdav/parsers.py new file mode 100644 index 0000000000..32524a54e0 --- /dev/null +++ b/music_assistant/providers/webdav/parsers.py @@ -0,0 +1,426 @@ +"""Parsing utilities for WebDAV filesystem provider.""" + +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ContentType, ExternalID, ImageType +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + AudioFormat, + ItemMapping, + MediaItemChapter, + MediaItemImage, + Podcast, + PodcastEpisode, + ProviderMapping, + Track, + UniqueList, +) + +from music_assistant.constants import VARIOUS_ARTISTS_MBID, VARIOUS_ARTISTS_NAME +from music_assistant.helpers.compare import compare_strings, create_safe_string +from music_assistant.helpers.tags import AudioTags +from music_assistant.helpers.util import parse_title_and_version +from music_assistant.providers.filesystem_local.helpers import ( + FileSystemItem, + get_album_dir, + get_artist_dir, +) + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + + +def _create_provider_mapping( + file_item: FileSystemItem, instance_id: str, domain: str, tags: AudioTags | None = None +) -> ProviderMapping: + """Create a ProviderMapping with AudioFormat from file item and optional tags.""" + # Determine content type - prefer file extension, fallback to tags format, then "unknown" + content_type_str = file_item.ext or (tags.format if tags else None) or "unknown" + + return ProviderMapping( + item_id=file_item.relative_path, + provider_domain=domain, + provider_instance=instance_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(content_type_str), + sample_rate=tags.sample_rate if tags and tags.sample_rate else 44100, + bit_depth=tags.bits_per_sample if tags and tags.bits_per_sample else 16, + channels=tags.channels if tags and tags.channels else 2, + bit_rate=tags.bit_rate if tags else None, + ), + details=file_item.checksum, + ) + + +def parse_track_from_tags( + file_item: FileSystemItem, + tags: AudioTags, + instance_id: str, + domain: str, + config: ProviderConfig, +) -> Track: + """Parse track from AudioTags using MA's logic.""" + name, version = parse_title_and_version(tags.title, tags.version) + provider_mapping = _create_provider_mapping(file_item, instance_id, domain, tags) + + track = Track( + item_id=file_item.relative_path, + provider=instance_id, + name=name, + sort_name=tags.title_sort, + version=version, + provider_mappings={provider_mapping}, + disc_number=tags.disc or 0, + track_number=tags.track or 0, + ) + + # Add external IDs + if isrc_tags := tags.isrc: + for isrsc in isrc_tags: + track.external_ids.add((ExternalID.ISRC, isrsc)) + + if acoustid := tags.get("acoustid"): + track.external_ids.add((ExternalID.ACOUSTID, acoustid)) + + # Parse album (if present) + if tags.album: + track.album = parse_album_from_tags( + track_path=file_item.relative_path, + track_tags=tags, + instance_id=instance_id, + domain=domain, + config=config, + ) + + # Parse track artists + for index, track_artist_str in enumerate(tags.artists): + # Prefer album artist if match + if ( + track.album + and isinstance(track.album, Album) + and ( + album_artist_match := next( + (x for x in track.album.artists if x.name == track_artist_str), None + ) + ) + ): + track.artists.append(album_artist_match) + continue + artist = parse_artist_from_tags( + track_artist_str, + instance_id, + domain, + album_dir=str(PurePosixPath(file_item.relative_path).parent) if track.album else None, + sort_name=( + tags.artist_sort_names[index] if index < len(tags.artist_sort_names) else None + ), + mbid=( + tags.musicbrainz_artistids[index] + if index < len(tags.musicbrainz_artistids) + else None + ), + ) + track.artists.append(artist) + + # Set other metadata + track.duration = int(tags.duration or 0) + track.metadata.genres = set(tags.genres) + track.metadata.copyright = tags.get("copyright") + track.metadata.lyrics = tags.lyrics + track.metadata.description = tags.get("comment") + + explicit_tag = tags.get("itunesadvisory") + if explicit_tag is not None: + track.metadata.explicit = explicit_tag == "1" + + if tags.musicbrainz_recordingid: + track.mbid = tags.musicbrainz_recordingid + + # Handle embedded cover image + if tags.has_cover_image: + track.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=file_item.relative_path, + provider=instance_id, + remotely_accessible=False, + ) + ] + ) + + return track + + +def parse_album_from_tags( + track_path: str, track_tags: AudioTags, instance_id: str, domain: str, config: ProviderConfig +) -> Album: + """Parse Album metadata from Track tags.""" + assert track_tags.album + + # Work out if we have an album and/or disc folder + track_dir = str(PurePosixPath(track_path).parent) + album_dir = get_album_dir(track_dir, track_tags.album) + + # Album artist(s) + album_artists: UniqueList[Artist | ItemMapping] = UniqueList() + if track_tags.album_artists: + for index, album_artist_str in enumerate(track_tags.album_artists): + artist = parse_artist_from_tags( + album_artist_str, + instance_id, + domain, + album_dir=album_dir, + sort_name=( + track_tags.album_artist_sort_names[index] + if index < len(track_tags.album_artist_sort_names) + else None + ), + mbid=( + track_tags.musicbrainz_albumartistids[index] + if index < len(track_tags.musicbrainz_albumartistids) + else None + ), + ) + album_artists.append(artist) + else: + # Album artist tag is missing, determine fallback + fallback_action = config.get_value("missing_album_artist_action") or "various_artists" + if fallback_action == "folder_name" and album_dir: + possible_artist_folder = str(PurePosixPath(album_dir).parent) + album_artist_str = PurePosixPath(possible_artist_folder).name + album_artists = UniqueList( + [ + parse_artist_from_tags( + name=album_artist_str, + instance_id=instance_id, + domain=domain, + album_dir=album_dir, + ) + ] + ) + elif fallback_action == "track_artist": + album_artists = UniqueList( + [ + parse_artist_from_tags( + name=track_artist_str, + instance_id=instance_id, + domain=domain, + album_dir=album_dir, + ) + for track_artist_str in track_tags.artists + ] + ) + else: + # Fallback to various artists + album_artists = UniqueList( + [ + parse_artist_from_tags( + name=VARIOUS_ARTISTS_NAME, + instance_id=instance_id, + domain=domain, + mbid=VARIOUS_ARTISTS_MBID, + ) + ] + ) + + # Follow filesystem_local pattern for item_id + item_id = album_dir or album_artists[0].name + "/" + track_tags.album + + name, version = parse_title_and_version(track_tags.album) + album = Album( + item_id=item_id, + provider=instance_id, + name=name, + version=version, + sort_name=track_tags.album_sort, + artists=album_artists, + provider_mappings={ + ProviderMapping( + item_id=item_id, + provider_domain=domain, + provider_instance=instance_id, + url=album_dir, + ) + }, + ) + + if track_tags.year: + album.year = track_tags.year + album.album_type = track_tags.album_type + + if track_tags.barcode: + album.external_ids.add((ExternalID.BARCODE, track_tags.barcode)) + + if track_tags.musicbrainz_albumid: + album.mbid = track_tags.musicbrainz_albumid + + if track_tags.musicbrainz_releasegroupid: + album.add_external_id(ExternalID.MB_RELEASEGROUP, track_tags.musicbrainz_releasegroupid) + + return album + + +def parse_artist_from_tags( + name: str, + instance_id: str, + domain: str, + album_dir: str | None = None, + artist_path: str | None = None, + sort_name: str | None = None, + mbid: str | None = None, +) -> Artist: + """Parse artist from name and optional metadata.""" + if not artist_path: + # Hunt for the artist (metadata) path on disk + safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False) + + if album_dir and (foldermatch := get_artist_dir(name, album_dir=album_dir)): + # Try to find (album)artist folder based on album path + artist_path = foldermatch + elif "/" in name or "\\" in name: + # Name looks like a path + artist_path = name + elif compare_strings(name, safe_artist_name): + artist_path = safe_artist_name + + prov_artist_id = artist_path or name + artist = Artist( + item_id=prov_artist_id, + provider=instance_id, + name=name, + sort_name=sort_name, + provider_mappings={ + ProviderMapping( + item_id=prov_artist_id, + provider_domain=domain, + provider_instance=instance_id, + url=artist_path, + ) + }, + ) + + if mbid: + artist.mbid = mbid + + return artist + + +def parse_audiobook_from_tags( + file_item: FileSystemItem, + tags: AudioTags, + instance_id: str, + domain: str, + config: ProviderConfig, # noqa: ARG001 +) -> Audiobook: + """Parse audiobook from AudioTags.""" + # Prefer album name for audiobook, fallback to title + book_name = tags.album or tags.title + provider_mapping = _create_provider_mapping(file_item, instance_id, domain, tags) + + audiobook = Audiobook( + item_id=file_item.relative_path, + provider=instance_id, + name=book_name, + sort_name=tags.album_sort or tags.title_sort, + version=tags.version, + duration=int(tags.duration or 0), + provider_mappings={provider_mapping}, + ) + + # Set authors from writers or album artists or artists + audiobook.authors.set(tags.writers or tags.album_artists or tags.artists) + audiobook.metadata.genres = set(tags.genres) + audiobook.metadata.copyright = tags.get("copyright") + audiobook.metadata.description = tags.get("comment") + + if tags.musicbrainz_recordingid: + audiobook.mbid = tags.musicbrainz_recordingid + + # Handle chapters if present + if tags.chapters: + audiobook.metadata.chapters = [ + MediaItemChapter( + position=chapter.chapter_id, + name=chapter.title or f"Chapter {chapter.chapter_id}", + start=chapter.position_start, + end=chapter.position_end, + ) + for chapter in tags.chapters + ] + + return audiobook + + +def parse_podcast_episode_from_tags( + file_item: FileSystemItem, tags: AudioTags, instance_id: str, domain: str +) -> PodcastEpisode: + """Parse podcast episode from AudioTags.""" + podcast_name = tags.album or PurePosixPath(file_item.relative_path).parent.name + podcast_path = str(PurePosixPath(file_item.relative_path).parent) + provider_mapping = _create_provider_mapping(file_item, instance_id, domain, tags) + + episode = PodcastEpisode( + item_id=file_item.relative_path, + provider=instance_id, + name=tags.title, + sort_name=tags.title_sort, + provider_mappings={provider_mapping}, + position=tags.track or 0, + duration=int(tags.duration or 0), + podcast=Podcast( + item_id=podcast_path, + provider=instance_id, + name=podcast_name, + sort_name=tags.album_sort, + publisher=tags.get("publisher"), + total_episodes=0, # Set to 0 - will be updated by MA during sync + provider_mappings={ + ProviderMapping( + item_id=podcast_path, + provider_domain=domain, + provider_instance=instance_id, + ) + }, + ), + ) + + # Set episode metadata + episode.metadata.genres = set(tags.genres) + episode.metadata.copyright = tags.get("copyright") + episode.metadata.description = tags.get("comment") + + # Handle chapters if present + if tags.chapters: + episode.metadata.chapters = [ + MediaItemChapter( + position=chapter.chapter_id, + name=chapter.title or f"Chapter {chapter.chapter_id}", + start=chapter.position_start, + end=chapter.position_end, + ) + for chapter in tags.chapters + ] + + # Handle embedded cover image + if tags.has_cover_image: + episode.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=file_item.relative_path, + provider=instance_id, + remotely_accessible=False, + ) + ) + + # Copy (embedded) image from episode to podcast + assert isinstance(episode.podcast, Podcast) + if not episode.podcast.image and episode.image: + episode.podcast.metadata.add_image(episode.image) + + return episode diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py new file mode 100644 index 0000000000..865c53cccc --- /dev/null +++ b/music_assistant/providers/webdav/provider.py @@ -0,0 +1,1233 @@ +"""WebDAV File System Provider for Music Assistant.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Sequence +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, cast +from urllib.parse import quote, unquote, urlparse, urlunparse + +import aiohttp +from music_assistant_models.enums import ImageType, MediaType, StreamType +from music_assistant_models.errors import ( + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, + SetupFailedError, +) +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + AudioFormat, + BrowseFolder, + ItemMapping, + MediaItemChapter, + MediaItemImage, + MediaItemType, + Podcast, + PodcastEpisode, + ProviderMapping, + Track, + UniqueList, +) +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.constants import ( + CONF_PASSWORD, + CONF_USERNAME, + DB_TABLE_PROVIDER_MAPPINGS, + VERBOSE_LOG_LEVEL, +) +from music_assistant.helpers.tags import async_parse_tags +from music_assistant.providers.filesystem_local import LocalFileSystemProvider +from music_assistant.providers.filesystem_local.constants import CACHE_CATEGORY_AUDIOBOOK_CHAPTERS +from music_assistant.providers.filesystem_local.helpers import FileSystemItem + +from .constants import ( + AUDIOBOOK_EXTENSIONS, + CONF_CONTENT_TYPE, + CONF_URL, + CONF_VERIFY_SSL, + IMAGE_EXTENSIONS, + MAX_CONCURRENT_TASKS, + PLAYLIST_EXTENSIONS, + PODCAST_EPISODE_EXTENSIONS, + SUPPORTED_EXTENSIONS, + TRACK_EXTENSIONS, + WEBDAV_TIMEOUT, +) +from .helpers import build_webdav_url, webdav_propfind, webdav_test_connection +from .parsers import ( + parse_audiobook_from_tags, + parse_podcast_episode_from_tags, + parse_track_from_tags, +) + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + 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._session: aiohttp.ClientSession | None = None + self.media_content_type = cast("str", config.get_value(CONF_CONTENT_TYPE)) + self.sync_running: bool = False + self.processed_audiobook_folders: set[str] = set() + + @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 Exception: + return "WebDAV" + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create HTTP session with proper authentication.""" + if self._session and not self._session.closed: + return self._session + + auth = None + if self.username: + auth = aiohttp.BasicAuth(self.username, self.password or "") + + connector = aiohttp.TCPConnector(ssl=self.verify_ssl) + + self._session = aiohttp.ClientSession( + auth=auth, + connector=connector, + timeout=aiohttp.ClientTimeout(total=WEBDAV_TIMEOUT), + ) + + return self._session + + 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 + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + if self._session and not self._session.closed: + await self._session.close() + await super().unload(is_removed) + + async def exists(self, file_path: str) -> bool: + """Return bool if this WebDAV resource exists.""" + if not file_path: + return False + + try: + webdav_url = build_webdav_url(self.base_url, file_path) + session = await self._get_session() + items = await webdav_propfind(session, webdav_url, depth=0) + 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(self, file_path: str) -> FileSystemItem: + """Resolve WebDAV path to FileSystemItem.""" + webdav_url = build_webdav_url(self.base_url, file_path) + session = await self._get_session() + + try: + items = await webdav_propfind(session, webdav_url, depth=0) + if not items: + if webdav_url.rstrip("/") == self.base_url.rstrip("/"): + return FileSystemItem( + filename="", + relative_path="", + absolute_path=webdav_url, + 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=webdav_url, + is_dir=webdav_item.is_dir, + checksum=webdav_item.last_modified or "unknown", + file_size=webdav_item.size, + ) + + except MediaNotFoundError: + raise + except Exception as err: + raise MediaNotFoundError(f"Failed to resolve WebDAV path {file_path}: {err}") from err + + def get_absolute_path(self, file_path: str) -> str: + """Return WebDAV URL for given file path.""" + return build_webdav_url(self.base_url, file_path) + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse this provider's items.""" + if self.media_content_type == "podcasts": + return await self.mass.music.podcasts.library_items(provider=self.instance_id) + if self.media_content_type == "audiobooks": + return await self.mass.music.audiobooks.library_items(provider=self.instance_id) + + items: list[MediaItemType | ItemMapping | BrowseFolder] = [] + item_path = path.split("://", 1)[1] if "://" in path else "" + + try: + filesystem_items = await self._scandir(item_path) + + for item in filesystem_items: + if not item.is_dir and ("." not in item.filename or not item.ext): + continue + + if item.is_dir: + items.append( + BrowseFolder( + item_id=item.relative_path, + provider=self.instance_id, + path=f"{self.instance_id}://{item.relative_path}", + name=item.filename, + is_playable=True, + ) + ) + elif item.ext in TRACK_EXTENSIONS: + items.append( + ItemMapping( + media_type=MediaType.TRACK, + item_id=item.relative_path, + provider=self.instance_id, + name=item.filename, + ) + ) + elif item.ext in PLAYLIST_EXTENSIONS: + items.append( + ItemMapping( + media_type=MediaType.PLAYLIST, + item_id=item.relative_path, + provider=self.instance_id, + name=item.filename, + ) + ) + except Exception as err: + self.logger.error(f"Failed to browse WebDAV path {item_path}: {err}") + + return items + + async def get_item_by_uri(self, uri: str) -> MediaItemType: + """Get a single media item by URI.""" + item_path = uri.split("://", 1)[1] if "://" in uri else uri + file_item = await self.resolve(item_path) + + # Folders aren't returned by get_item_by_uri - MA handles them differently + if file_item.is_dir: + raise MediaNotFoundError(f"Cannot get media item for directory: {item_path}") + + # Determine type from extension and return appropriate item + ext = ( + PurePosixPath(file_item.filename).suffix.lstrip(".").lower() + if "." in file_item.filename + else None + ) + + if ext in TRACK_EXTENSIONS: + return await self.get_track(item_path) + + if ext in PLAYLIST_EXTENSIONS: + raise NotImplementedError("Playlist retrieval not yet implemented") + + if ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks": + raise NotImplementedError("Audiobook retrieval not yet implemented") + + if ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts": + raise NotImplementedError("Podcast episode retrieval not yet implemented") + + raise MediaNotFoundError(f"Unsupported file type: {file_item.filename}") + + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + file_item = await self.resolve(prov_track_id) + + # Build authenticated URL for ffprobe + parsed = urlparse(file_item.absolute_path) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) + ) + else: + auth_url = file_item.absolute_path + + tags = await async_parse_tags(auth_url, file_item.file_size) + return parse_track_from_tags(file_item, tags, self.instance_id, self.domain, self.config) + + async def get_album(self, prov_album_id: str) -> Album: + """Get album by id - reconstructed from tracks.""" + items = await self._scandir(prov_album_id) + for item in items: + if item.ext in TRACK_EXTENSIONS: + track = await self.get_track(item.relative_path) + if track.album and isinstance(track.album, Album): + album = track.album + # Scan for folder images on every get_album call + folder_images = await self._get_local_images(prov_album_id) + if folder_images: + album.metadata.images = folder_images + return album + raise MediaNotFoundError(f"No tracks found in album path: {prov_album_id}") + + async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: + """Get full audiobook details by id.""" + file_item = await self.resolve(prov_audiobook_id) + + # If it's a folder (multi-file audiobook), get the first file + if file_item.is_dir: + items = await self._scandir(prov_audiobook_id) + audiobook_files = [f for f in items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS] + if not audiobook_files: + raise MediaNotFoundError(f"No audiobook files found in folder: {prov_audiobook_id}") + + # Use first file for metadata + sorted_files = sorted(audiobook_files, key=lambda x: x.filename) + file_item = sorted_files[0] + file_path = file_item.relative_path + else: + # Single file audiobook + file_path = prov_audiobook_id + + # Build authenticated URL for ffprobe + webdav_url = build_webdav_url(self.base_url, file_path) + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, file_item.file_size) + audiobook = parse_audiobook_from_tags( + file_item, tags, self.instance_id, self.domain, self.config + ) + + # For multi-file audiobooks, override the item_id to be the folder + if file_item.relative_path != prov_audiobook_id: + audiobook.item_id = prov_audiobook_id + + return audiobook + + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Get full podcast details by id.""" + # For podcasts, the ID is the folder path containing episodes + # Scan for any episode file to get podcast metadata + items = await self._scandir(prov_podcast_id) + episode_files = [f for f in items if not f.is_dir and f.ext in PODCAST_EPISODE_EXTENSIONS] + + if not episode_files: + raise MediaNotFoundError(f"No podcast episodes found in: {prov_podcast_id}") + + # Parse first episode to get podcast metadata + first_episode_file = sorted(episode_files, key=lambda x: x.filename)[0] + + webdav_url = build_webdav_url(self.base_url, first_episode_file.relative_path) + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, first_episode_file.file_size) + episode = parse_podcast_episode_from_tags( + first_episode_file, tags, self.instance_id, self.domain + ) + + # Return the podcast object from the episode + assert isinstance(episode.podcast, Podcast) + return episode.podcast + + async def get_podcast_episodes( + self, + prov_podcast_id: str, + ) -> AsyncGenerator[PodcastEpisode, None]: + """Get all episodes for a podcast.""" + items = await self._scandir(prov_podcast_id) + episode_files = [f for f in items if not f.is_dir and f.ext in PODCAST_EPISODE_EXTENSIONS] + + for episode_file in sorted(episode_files, key=lambda x: x.filename): + webdav_url = build_webdav_url(self.base_url, episode_file.relative_path) + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, episode_file.file_size) + episode = parse_podcast_episode_from_tags( + episode_file, tags, self.instance_id, self.domain + ) + yield episode + + async def sync_library(self, media_type: MediaType, import_as_favorite: bool = False) -> None: + """Run library sync for WebDAV provider.""" + 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 + self.processed_audiobook_folders.clear() + + try: + assert self.mass.music.database + 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 get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Return the content details for the given media when it will be streamed.""" + if media_type == MediaType.TRACK: + return await self._get_track_stream_details(item_id) + elif media_type == MediaType.AUDIOBOOK: + return await self._get_audiobook_stream_details(item_id) + elif media_type == MediaType.PODCAST_EPISODE: + return await self._get_podcast_stream_details(item_id) + else: + raise NotImplementedError(f"Streaming not implemented for {media_type}") + + async def _get_track_stream_details(self, item_id: str) -> StreamDetails: + """Get stream details for a track.""" + track = await self.mass.music.tracks.get_library_item_by_prov_id(item_id, self.instance_id) + if track is None: + self.logger.debug(f"Track not in library, parsing from file: {item_id}") + track = await self.get_track(item_id) + + return await self._build_http_stream_details(item_id, track, MediaType.TRACK) + + async def _get_audiobook_stream_details(self, item_id: str) -> StreamDetails: + """Get stream details for an audiobook.""" + # Get or parse audiobook + audiobook = await self.mass.music.audiobooks.get_library_item_by_prov_id( + item_id, self.instance_id + ) + if audiobook is None: + audiobook = await self._parse_audiobook_from_file(item_id) + + # Get audio format + prov_mapping = next((x for x in audiobook.provider_mappings if x.item_id == item_id), None) + audio_format = prov_mapping.audio_format if prov_mapping else AudioFormat() + + # Check if multi-file audiobook + file_item = await self.resolve(item_id) + file_based_chapters = await self._get_audiobook_chapters_from_cache(item_id, file_item) + + if file_based_chapters: + return await self._build_multifile_audiobook_stream( + item_id, audiobook, audio_format, file_based_chapters + ) + + # Single-file audiobook + return await self._build_http_stream_details(item_id, audiobook, MediaType.AUDIOBOOK) + + async def _get_podcast_stream_details(self, item_id: str) -> StreamDetails: + """Get stream details for a podcast episode.""" + try: + file_item = await self.resolve(item_id) + auth_url = self._build_authenticated_url(item_id) + + tags = await async_parse_tags(auth_url, file_item.file_size) + episode = parse_podcast_episode_from_tags( + file_item, tags, self.instance_id, self.domain + ) + + return await self._build_http_stream_details( + item_id, episode, MediaType.PODCAST_EPISODE + ) + + except Exception as err: + self.logger.exception(f"Failed to process podcast episode {item_id}: {err}") + raise MediaNotFoundError(f"Failed to process podcast episode: {item_id}") from err + + async def _parse_audiobook_from_file(self, item_id: str) -> Audiobook: + """Parse audiobook from file when not in library.""" + file_item = await self.resolve(item_id) + auth_url = self._build_authenticated_url(item_id) + tags = await async_parse_tags(auth_url, file_item.file_size) + audiobook = await self._parse_audiobook(file_item, tags) + + if not audiobook: + raise MediaNotFoundError(f"Audiobook not found: {item_id}") + + return audiobook + + async def _get_audiobook_chapters_from_cache( + self, item_id: str, file_item: FileSystemItem + ) -> list[tuple[str, float]] | None: + """Get audiobook chapters from cache, rebuilding if necessary.""" + file_based_chapters = await self.cache.get( + key=item_id, + provider=self.instance_id, + category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, + ) + + if file_based_chapters is None and file_item.is_dir: + self.logger.debug(f"Cache miss for audiobook chapters, re-parsing: {item_id}") + items = await self._scandir(item_id) + audiobook_files = [f for f in items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS] + + if audiobook_files: + await self._process_multifile_audiobook(item_id, audiobook_files, None) + file_based_chapters = cast( + "list[tuple[str, float]] | None", + await self.cache.get( + key=item_id, + provider=self.instance_id, + category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, + ), + ) + return cast("list[tuple[str, float]] | None", file_based_chapters) + + async def _build_multifile_audiobook_stream( + self, + item_id: str, + audiobook: Audiobook, + audio_format: AudioFormat, + file_based_chapters: list[tuple[str, float]], + ) -> StreamDetails: + """Build stream details for multi-file audiobook.""" + chapter_urls = [] + for chapter_path, _ in file_based_chapters: + auth_url = self._build_authenticated_url(chapter_path) + chapter_urls.append(auth_url) + + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=audio_format, + media_type=MediaType.AUDIOBOOK, + stream_type=StreamType.CUSTOM, + duration=audiobook.duration, + data={ + "chapters": chapter_urls, + "chapters_data": file_based_chapters, + }, + allow_seek=True, + can_seek=True, + ) + + async def _build_http_stream_details( + self, + item_id: str, + library_item: Track | Audiobook | PodcastEpisode, + media_type: MediaType, + ) -> StreamDetails: + """Build stream details for single HTTP file.""" + auth_url = self._build_authenticated_url(item_id) + + prov_mapping = next( + (x for x in library_item.provider_mappings if x.item_id == item_id), None + ) + audio_format = prov_mapping.audio_format if prov_mapping else AudioFormat() + + file_size = None + try: + file_item = await self.resolve(item_id) + file_size = file_item.file_size + except Exception as err: + self.logger.debug(f"Could not get file size for {item_id}: {err}") + + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=audio_format, + media_type=media_type, + stream_type=StreamType.HTTP, + duration=library_item.duration, + size=file_size, + path=auth_url, + can_seek=True, + allow_seek=True, + ) + + 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 get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Get audio stream for WebDAV items.""" + if streamdetails.media_type == MediaType.AUDIOBOOK and isinstance(streamdetails.data, dict): + async for chunk in self._stream_multifile_audiobook(streamdetails, seek_position): + yield chunk + else: + raise NotImplementedError("Use HTTP stream type for single files") + + async def _stream_multifile_audiobook( + self, streamdetails: StreamDetails, seek_position: int + ) -> AsyncGenerator[bytes, None]: + """Stream multi-file audiobook chapters.""" + chapter_urls = streamdetails.data.get("chapters", []) + chapters_data = streamdetails.data.get("chapters_data", []) + + # Calculate starting chapter + start_chapter = self._calculate_start_chapter(chapters_data, seek_position) + + # Stream chapters + chapters_yielded = False + for i in range(start_chapter, len(chapter_urls)): + chapter_url = chapter_urls[i] + + try: + async with self.mass.http_session.get(chapter_url) as response: + response.raise_for_status() + async for chunk in response.content.iter_chunked(8192): + chapters_yielded = True + yield chunk + + except Exception as e: + self.logger.exception(f"Chapter {i + 1} streaming failed: {e}") + continue + + if not chapters_yielded: + raise MediaNotFoundError( + f"Failed to stream any chapters for audiobook {streamdetails.item_id}" + ) + + def _calculate_start_chapter( + self, chapters_data: list[tuple[str, float]], seek_position: int + ) -> int: + """Calculate which chapter to start from based on seek position.""" + # Define a small tolerance margin (e.g., 100 milliseconds) + # This handles cases where seek_position is slightly less than the chapter's start time + tolerance_seconds = 2.0 + + if seek_position <= 0: + return 0 + + accumulated_duration = 0.0 + + for i, (_, chapter_duration) in enumerate(chapters_data): + chapter_end_time = accumulated_duration + chapter_duration + + # If the seek is within TOLERANCE of the next chapter's start time, + # treat it as the next chapter's start time. + + # This is the time when chapter i ends, and chapter i+1 begins. + + if seek_position >= chapter_end_time - tolerance_seconds: + # If seek_position is at or just before the chapter end time (within tolerance), + # we treat it as seeking to the NEXT chapter. + accumulated_duration = chapter_end_time + continue + + # Otherwise, the seek position is clearly within the bounds of chapter i. + return i + + # Seek position beyond total duration - start from last chapter + return max(0, len(chapters_data) - 1) + + async def _scandir(self, path: str) -> list[FileSystemItem]: + """List WebDAV directory contents.""" + webdav_url = build_webdav_url(self.base_url, path) + session = await self._get_session() + + try: + webdav_items = await webdav_propfind(session, webdav_url, depth=1) + filesystem_items: list[FileSystemItem] = [] + + for webdav_item in webdav_items: + # SKIP RECYCLE BIN + if "#recycle" in webdav_item.name.lower(): + continue + decoded_href = unquote(webdav_item.href) + decoded_base_url = unquote(self.base_url) + + # Extract path from full URL for comparison + parsed_webdav_url = urlparse(webdav_url) + webdav_path = parsed_webdav_url.path.rstrip("/") + + # Skip the directory itself + if decoded_href.rstrip("/") == webdav_path: + continue + + if decoded_href.startswith(decoded_base_url): + relative_path = decoded_href[len(decoded_base_url) :].strip("/") + else: + decoded_name = unquote(webdav_item.name) + relative_path = ( + str(PurePosixPath(path) / decoded_name) if path else decoded_name + ) + + decoded_name = unquote(webdav_item.name) + + filesystem_items.append( + FileSystemItem( + filename=decoded_name, + relative_path=relative_path, + absolute_path=webdav_item.href, + is_dir=webdav_item.is_dir, + checksum=webdav_item.last_modified or "unknown", + file_size=webdav_item.size, + ) + ) + + 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.log( + VERBOSE_LOG_LEVEL, + f"Failed to list WebDAV directory {path}: {err}", + ) + return [] + + 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 with concurrent processing.""" + try: + items = await self._scandir(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] + + # ADD SEMAPHORE FOR DIRECTORY SCANNING + dir_semaphore = asyncio.Semaphore(3) # Limit concurrent directory scans + + async def scan_dir_limited(item: FileSystemItem) -> None: + async with dir_semaphore: + await self._scan_recursive( + item.relative_path, cur_filenames, file_checksums, import_as_favorite + ) + + dir_tasks = [scan_dir_limited(item) for item in dirs] + + # Process files concurrently with semaphore + semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS) + + async def process_with_semaphore(item: FileSystemItem) -> None: + async with semaphore: + prev_checksum = file_checksums.get(item.relative_path) + if await self._process_webdav_item(item, prev_checksum, import_as_favorite): + cur_filenames.add(item.relative_path) + + file_tasks = [process_with_semaphore(item) for item in files] + + # Run all tasks concurrently + all_tasks = dir_tasks + file_tasks + results = await asyncio.gather(*all_tasks, return_exceptions=True) + + # Log any errors + for result in results: + if isinstance(result, Exception) and not isinstance( + result, (LoginFailed, SetupFailedError, ProviderUnavailableError) + ): + self.logger.warning(f"Error during scan: {result}") + + 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}") + + async def _process_webdav_item( + self, + item: FileSystemItem, + prev_checksum: str | None, + import_as_favorite: bool, + ) -> bool: + """Process a single WebDAV item during library sync.""" + if item.is_dir: + return False + + if not item.ext or item.ext not in SUPPORTED_EXTENSIONS: + return False + + try: + self.logger.log(VERBOSE_LOG_LEVEL, f"Processing WebDAV item: {item.relative_path}") + + # Skip if unchanged + if item.checksum == prev_checksum: + return True + + # Process based on media type + if item.ext in TRACK_EXTENSIONS and self.media_content_type == "music": + await self._process_track(item, prev_checksum) + return True + + if item.ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks": + # Check if this is a multi-file audiobook folder + folder_path = str(PurePosixPath(item.relative_path).parent) + + # Skip if we've already processed this folder + if folder_path in self.processed_audiobook_folders: + return True + + # Scan folder to see if there are multiple audiobook files + folder_items = await self._scandir(folder_path) + audiobook_files = [ + f for f in folder_items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS + ] + + if len(audiobook_files) > 1: + # Multi-file audiobook - process entire folder + await self._process_multifile_audiobook( + folder_path, audiobook_files, prev_checksum + ) + self.processed_audiobook_folders.add(folder_path) + else: + # Single file audiobook + await self._process_audiobook(item, prev_checksum) + return True + + if item.ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts": + await self._process_podcast(item, prev_checksum) + return True + + except Exception as err: + self.logger.error( + f"Error processing WebDAV item {item.relative_path}: {err}", + exc_info=self.logger.isEnabledFor(10), + ) + return False + + async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) -> None: + """Process a track item.""" + try: + # Build full WebDAV URL from relative path + webdav_url = build_webdav_url(self.base_url, item.relative_path) + + # Add authentication to URL for ffprobe + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, item.file_size) + track = parse_track_from_tags(item, tags, self.instance_id, self.domain, self.config) + + # Add folder images to album if present + if track.album and isinstance(track.album, Album): + album_path = str(PurePosixPath(item.relative_path).parent) + folder_images = await self._get_local_images(album_path) + if folder_images and not track.album.metadata.images: + track.album.metadata.images = folder_images + + # Add folder images to album artists + for artist in track.album.artists: + if isinstance(artist, Artist) and not artist.metadata.images: + # Try to get artist folder from provider mapping URL + artist_mapping = next( + ( + m + for m in artist.provider_mappings + if m.provider_instance == self.instance_id + ), + None, + ) + if artist_mapping and artist_mapping.url: + artist_images = await self._get_local_images( + artist_mapping.url, extra_thumb_names=("artist",) + ) + if artist_images: + artist.metadata.images = artist_images + + # Also add images to track artists + for artist in track.artists: + if isinstance(artist, Artist) and not artist.metadata.images: + artist_mapping = next( + ( + m + for m in artist.provider_mappings + if m.provider_instance == self.instance_id + ), + None, + ) + if artist_mapping and artist_mapping.url: + artist_images = await self._get_local_images( + artist_mapping.url, extra_thumb_names=("artist",) + ) + if artist_images: + artist.metadata.images = artist_images + + await self.mass.music.tracks.add_item_to_library( + track, overwrite_existing=prev_checksum is not None + ) + except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: + self.logger.error(f"Provider error processing WebDAV track {item.relative_path}: {err}") + raise + except aiohttp.ClientError as err: + self.logger.error( + f"Connection error processing WebDAV track {item.relative_path}: {err}" + ) + except Exception as err: + self.logger.error(f"Failed to process WebDAV track {item.relative_path}: {err}") + + async def _process_audiobook(self, item: FileSystemItem, prev_checksum: str | None) -> None: + """Process an audiobook item.""" + try: + # Build full WebDAV URL from relative path + webdav_url = build_webdav_url(self.base_url, item.relative_path) + + # Add authentication to URL for ffprobe + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, item.file_size) + audiobook = parse_audiobook_from_tags( + item, tags, self.instance_id, self.domain, self.config + ) + await self.mass.music.audiobooks.add_item_to_library( + audiobook, overwrite_existing=prev_checksum is not None + ) + except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: + self.logger.error( + f"Provider error processing WebDAV audiobook {item.relative_path}: {err}" + ) + raise + except aiohttp.ClientError as err: + self.logger.error( + f"Connection error processing WebDAV audiobook {item.relative_path}: {err}" + ) + except Exception as err: + self.logger.error(f"Failed to process WebDAV audiobook {item.relative_path}: {err}") + + async def _process_multifile_audiobook( + self, + folder_path: str, + audiobook_files: list[FileSystemItem], + prev_checksum: str | None, + ) -> None: + """Process a multi-file audiobook folder.""" + try: + # Sort files by name + sorted_files = sorted(audiobook_files, key=lambda x: x.filename) + + # Parse first file to get audiobook metadata + first_file = sorted_files[0] + webdav_url = build_webdav_url(self.base_url, first_file.relative_path) + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, first_file.file_size) + + # Create audiobook from folder + audiobook = parse_audiobook_from_tags( + first_file, tags, self.instance_id, self.domain, self.config + ) + + # Override item_id to be the folder + audiobook.item_id = folder_path + audiobook.provider_mappings = { + ProviderMapping( + item_id=folder_path, + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + } + + # Build chapters from all files + chapters: list[MediaItemChapter] = [] + chapter_files: list[tuple[str, float]] = [] + total_duration = 0.0 + + for idx, file_item in enumerate(sorted_files, start=1): + file_url = build_webdav_url(self.base_url, file_item.relative_path) + parsed = urlparse(file_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + file_auth_url = urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + else: + file_auth_url = file_url + + file_tags = await async_parse_tags(file_auth_url, file_item.file_size) + duration = file_tags.duration or 0 + + chapters.append( + MediaItemChapter( + position=idx, + name=file_tags.title or file_item.filename, + start=total_duration, + end=total_duration + duration, + ) + ) + chapter_files.append((file_item.relative_path, duration)) + total_duration += duration + + audiobook.duration = int(total_duration) + audiobook.metadata.chapters = chapters + + # Cache chapter files for streaming lookup + await self.cache.set( + key=folder_path, + data=chapter_files, + provider=self.instance_id, + category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, + ) + + # Add folder images + folder_images = await self._get_local_images(folder_path) + if folder_images: + audiobook.metadata.images = folder_images + + # Store audiobook + await self.mass.music.audiobooks.add_item_to_library( + audiobook, overwrite_existing=prev_checksum is not None + ) + + except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: + self.logger.error( + f"Provider error processing multi-file audiobook {folder_path}: {err}" + ) + raise + except aiohttp.ClientError as err: + self.logger.error( + f"Connection error processing multi-file audiobook {folder_path}: {err}" + ) + except Exception as err: + self.logger.error(f"Failed to process multi-file audiobook {folder_path}: {err}") + + async def _process_podcast(self, item: FileSystemItem, prev_checksum: str | None) -> None: + """Process a podcast episode item.""" + try: + # Build full WebDAV URL from relative path + webdav_url = build_webdav_url(self.base_url, item.relative_path) + + # Add authentication to URL for ffprobe + parsed = urlparse(webdav_url) + if self.username and self.password: + netloc = f"{self.username}:{self.password}@{parsed.netloc}" + auth_url = urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + else: + auth_url = webdav_url + + tags = await async_parse_tags(auth_url, item.file_size) + episode = parse_podcast_episode_from_tags(item, tags, self.instance_id, self.domain) + podcast_folder = str(PurePosixPath(item.relative_path).parent) + folder_images = await self._get_local_images(podcast_folder) + assert isinstance(episode.podcast, Podcast) + if folder_images: + episode.podcast.metadata.images = folder_images + await self.mass.music.podcasts.add_item_to_library( + episode.podcast, overwrite_existing=prev_checksum is not None + ) + except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: + self.logger.error( + f"Provider error processing WebDAV podcast {item.relative_path}: {err}" + ) + raise + except aiohttp.ClientError as err: + self.logger.error( + f"Connection error processing WebDAV podcast {item.relative_path}: {err}" + ) + except Exception as err: + self.logger.error(f"Failed to process WebDAV podcast {item.relative_path}: {err}") + + async def _get_local_images( + self, + item_path: str, + extra_thumb_names: tuple[str, ...] | None = None, + ) -> UniqueList[MediaItemImage]: + """Get images from WebDAV folder.""" + try: + # Scan the WebDAV directory for image files + items = await self._scandir(item_path) + images: list[MediaItemImage] = [] + + # Standard image filenames to look for + standard_names = {"folder", "cover", "albumart", "album", "front"} + if extra_thumb_names: + standard_names.update(extra_thumb_names) + + for item in items: + if item.is_dir or not item.ext: + continue + + if item.ext in IMAGE_EXTENSIONS: + # Check if it's a standard cover image name + name_lower = item.filename.lower().rsplit(".", 1)[0] + if name_lower in standard_names: + webdav_url = build_webdav_url(self.base_url, item.relative_path) + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=webdav_url, + provider=self.instance_id, + remotely_accessible=False, + ) + ) + + return UniqueList(images) + except Exception as err: + self.logger.debug(f"Failed to get images from {item_path}: {err}") + return UniqueList() + + async def resolve_image(self, path: str) -> str | bytes: + """Resolve image path to actual image data or URL.""" + # Build WebDAV URL + webdav_url = build_webdav_url(self.base_url, path) + + # Fetch the image data with authentication + session = await self._get_session() + async with session.get(webdav_url) as resp: + if resp.status != 200: + raise MediaNotFoundError(f"Image not found: {path}") + return await resp.read() From 7f9cfd8b417fa60a1bb9d04ac1d2565ff6ed6548 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 6 Oct 2025 17:20:34 +1000 Subject: [PATCH 02/10] PR Review --- music_assistant/providers/webdav/helpers.py | 40 +- music_assistant/providers/webdav/parsers.py | 426 ----------- music_assistant/providers/webdav/provider.py | 764 +++++++++---------- 3 files changed, 346 insertions(+), 884 deletions(-) delete mode 100644 music_assistant/providers/webdav/parsers.py diff --git a/music_assistant/providers/webdav/helpers.py b/music_assistant/providers/webdav/helpers.py index c22991fdc5..c9a76d35ee 100644 --- a/music_assistant/providers/webdav/helpers.py +++ b/music_assistant/providers/webdav/helpers.py @@ -42,6 +42,7 @@ async def webdav_propfind( url: str, depth: int = 1, timeout: int = 30, + auth: aiohttp.BasicAuth | None = None, ) -> list[WebDAVItem]: """ Execute a PROPFIND request on a WebDAV resource. @@ -80,6 +81,7 @@ async def webdav_propfind( url, headers=headers, data=body, + auth=auth, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: if resp.status == 401: @@ -173,43 +175,13 @@ def _parse_propfind_response(response_text: str, base_url: str) -> list[WebDAVIt async def webdav_test_connection( + session: aiohttp.ClientSession, # Pass session in instead of creating url: str, - username: str | None = None, - password: str | None = None, - verify_ssl: bool = True, + auth: aiohttp.BasicAuth | None = None, timeout: int = 10, ) -> None: - """ - Test WebDAV connection and authentication. - - Args: - url: WebDAV server URL - username: Optional username - password: Optional password - verify_ssl: Whether to verify SSL certificates - timeout: Connection timeout - - Raises: - LoginFailed: Authentication or connection failed - SetupFailedError: Server configuration issues - """ - auth = None - if username: - auth = aiohttp.BasicAuth(username, password or "") - - connector = aiohttp.TCPConnector(ssl=verify_ssl) - - try: - async with aiohttp.ClientSession( - auth=auth, - connector=connector, - timeout=aiohttp.ClientTimeout(total=timeout), - ) as session: - # Try a simple PROPFIND to test connectivity - await webdav_propfind(session, url, depth=0, timeout=timeout) - finally: - if connector: - await connector.close() + """Test WebDAV connection and authentication.""" + await webdav_propfind(session, url, depth=0, timeout=timeout, auth=auth) def normalize_webdav_url(url: str) -> str: diff --git a/music_assistant/providers/webdav/parsers.py b/music_assistant/providers/webdav/parsers.py deleted file mode 100644 index 32524a54e0..0000000000 --- a/music_assistant/providers/webdav/parsers.py +++ /dev/null @@ -1,426 +0,0 @@ -"""Parsing utilities for WebDAV filesystem provider.""" - -from __future__ import annotations - -from pathlib import PurePosixPath -from typing import TYPE_CHECKING - -from music_assistant_models.enums import ContentType, ExternalID, ImageType -from music_assistant_models.media_items import ( - Album, - Artist, - Audiobook, - AudioFormat, - ItemMapping, - MediaItemChapter, - MediaItemImage, - Podcast, - PodcastEpisode, - ProviderMapping, - Track, - UniqueList, -) - -from music_assistant.constants import VARIOUS_ARTISTS_MBID, VARIOUS_ARTISTS_NAME -from music_assistant.helpers.compare import compare_strings, create_safe_string -from music_assistant.helpers.tags import AudioTags -from music_assistant.helpers.util import parse_title_and_version -from music_assistant.providers.filesystem_local.helpers import ( - FileSystemItem, - get_album_dir, - get_artist_dir, -) - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig - - -def _create_provider_mapping( - file_item: FileSystemItem, instance_id: str, domain: str, tags: AudioTags | None = None -) -> ProviderMapping: - """Create a ProviderMapping with AudioFormat from file item and optional tags.""" - # Determine content type - prefer file extension, fallback to tags format, then "unknown" - content_type_str = file_item.ext or (tags.format if tags else None) or "unknown" - - return ProviderMapping( - item_id=file_item.relative_path, - provider_domain=domain, - provider_instance=instance_id, - audio_format=AudioFormat( - content_type=ContentType.try_parse(content_type_str), - sample_rate=tags.sample_rate if tags and tags.sample_rate else 44100, - bit_depth=tags.bits_per_sample if tags and tags.bits_per_sample else 16, - channels=tags.channels if tags and tags.channels else 2, - bit_rate=tags.bit_rate if tags else None, - ), - details=file_item.checksum, - ) - - -def parse_track_from_tags( - file_item: FileSystemItem, - tags: AudioTags, - instance_id: str, - domain: str, - config: ProviderConfig, -) -> Track: - """Parse track from AudioTags using MA's logic.""" - name, version = parse_title_and_version(tags.title, tags.version) - provider_mapping = _create_provider_mapping(file_item, instance_id, domain, tags) - - track = Track( - item_id=file_item.relative_path, - provider=instance_id, - name=name, - sort_name=tags.title_sort, - version=version, - provider_mappings={provider_mapping}, - disc_number=tags.disc or 0, - track_number=tags.track or 0, - ) - - # Add external IDs - if isrc_tags := tags.isrc: - for isrsc in isrc_tags: - track.external_ids.add((ExternalID.ISRC, isrsc)) - - if acoustid := tags.get("acoustid"): - track.external_ids.add((ExternalID.ACOUSTID, acoustid)) - - # Parse album (if present) - if tags.album: - track.album = parse_album_from_tags( - track_path=file_item.relative_path, - track_tags=tags, - instance_id=instance_id, - domain=domain, - config=config, - ) - - # Parse track artists - for index, track_artist_str in enumerate(tags.artists): - # Prefer album artist if match - if ( - track.album - and isinstance(track.album, Album) - and ( - album_artist_match := next( - (x for x in track.album.artists if x.name == track_artist_str), None - ) - ) - ): - track.artists.append(album_artist_match) - continue - artist = parse_artist_from_tags( - track_artist_str, - instance_id, - domain, - album_dir=str(PurePosixPath(file_item.relative_path).parent) if track.album else None, - sort_name=( - tags.artist_sort_names[index] if index < len(tags.artist_sort_names) else None - ), - mbid=( - tags.musicbrainz_artistids[index] - if index < len(tags.musicbrainz_artistids) - else None - ), - ) - track.artists.append(artist) - - # Set other metadata - track.duration = int(tags.duration or 0) - track.metadata.genres = set(tags.genres) - track.metadata.copyright = tags.get("copyright") - track.metadata.lyrics = tags.lyrics - track.metadata.description = tags.get("comment") - - explicit_tag = tags.get("itunesadvisory") - if explicit_tag is not None: - track.metadata.explicit = explicit_tag == "1" - - if tags.musicbrainz_recordingid: - track.mbid = tags.musicbrainz_recordingid - - # Handle embedded cover image - if tags.has_cover_image: - track.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=file_item.relative_path, - provider=instance_id, - remotely_accessible=False, - ) - ] - ) - - return track - - -def parse_album_from_tags( - track_path: str, track_tags: AudioTags, instance_id: str, domain: str, config: ProviderConfig -) -> Album: - """Parse Album metadata from Track tags.""" - assert track_tags.album - - # Work out if we have an album and/or disc folder - track_dir = str(PurePosixPath(track_path).parent) - album_dir = get_album_dir(track_dir, track_tags.album) - - # Album artist(s) - album_artists: UniqueList[Artist | ItemMapping] = UniqueList() - if track_tags.album_artists: - for index, album_artist_str in enumerate(track_tags.album_artists): - artist = parse_artist_from_tags( - album_artist_str, - instance_id, - domain, - album_dir=album_dir, - sort_name=( - track_tags.album_artist_sort_names[index] - if index < len(track_tags.album_artist_sort_names) - else None - ), - mbid=( - track_tags.musicbrainz_albumartistids[index] - if index < len(track_tags.musicbrainz_albumartistids) - else None - ), - ) - album_artists.append(artist) - else: - # Album artist tag is missing, determine fallback - fallback_action = config.get_value("missing_album_artist_action") or "various_artists" - if fallback_action == "folder_name" and album_dir: - possible_artist_folder = str(PurePosixPath(album_dir).parent) - album_artist_str = PurePosixPath(possible_artist_folder).name - album_artists = UniqueList( - [ - parse_artist_from_tags( - name=album_artist_str, - instance_id=instance_id, - domain=domain, - album_dir=album_dir, - ) - ] - ) - elif fallback_action == "track_artist": - album_artists = UniqueList( - [ - parse_artist_from_tags( - name=track_artist_str, - instance_id=instance_id, - domain=domain, - album_dir=album_dir, - ) - for track_artist_str in track_tags.artists - ] - ) - else: - # Fallback to various artists - album_artists = UniqueList( - [ - parse_artist_from_tags( - name=VARIOUS_ARTISTS_NAME, - instance_id=instance_id, - domain=domain, - mbid=VARIOUS_ARTISTS_MBID, - ) - ] - ) - - # Follow filesystem_local pattern for item_id - item_id = album_dir or album_artists[0].name + "/" + track_tags.album - - name, version = parse_title_and_version(track_tags.album) - album = Album( - item_id=item_id, - provider=instance_id, - name=name, - version=version, - sort_name=track_tags.album_sort, - artists=album_artists, - provider_mappings={ - ProviderMapping( - item_id=item_id, - provider_domain=domain, - provider_instance=instance_id, - url=album_dir, - ) - }, - ) - - if track_tags.year: - album.year = track_tags.year - album.album_type = track_tags.album_type - - if track_tags.barcode: - album.external_ids.add((ExternalID.BARCODE, track_tags.barcode)) - - if track_tags.musicbrainz_albumid: - album.mbid = track_tags.musicbrainz_albumid - - if track_tags.musicbrainz_releasegroupid: - album.add_external_id(ExternalID.MB_RELEASEGROUP, track_tags.musicbrainz_releasegroupid) - - return album - - -def parse_artist_from_tags( - name: str, - instance_id: str, - domain: str, - album_dir: str | None = None, - artist_path: str | None = None, - sort_name: str | None = None, - mbid: str | None = None, -) -> Artist: - """Parse artist from name and optional metadata.""" - if not artist_path: - # Hunt for the artist (metadata) path on disk - safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False) - - if album_dir and (foldermatch := get_artist_dir(name, album_dir=album_dir)): - # Try to find (album)artist folder based on album path - artist_path = foldermatch - elif "/" in name or "\\" in name: - # Name looks like a path - artist_path = name - elif compare_strings(name, safe_artist_name): - artist_path = safe_artist_name - - prov_artist_id = artist_path or name - artist = Artist( - item_id=prov_artist_id, - provider=instance_id, - name=name, - sort_name=sort_name, - provider_mappings={ - ProviderMapping( - item_id=prov_artist_id, - provider_domain=domain, - provider_instance=instance_id, - url=artist_path, - ) - }, - ) - - if mbid: - artist.mbid = mbid - - return artist - - -def parse_audiobook_from_tags( - file_item: FileSystemItem, - tags: AudioTags, - instance_id: str, - domain: str, - config: ProviderConfig, # noqa: ARG001 -) -> Audiobook: - """Parse audiobook from AudioTags.""" - # Prefer album name for audiobook, fallback to title - book_name = tags.album or tags.title - provider_mapping = _create_provider_mapping(file_item, instance_id, domain, tags) - - audiobook = Audiobook( - item_id=file_item.relative_path, - provider=instance_id, - name=book_name, - sort_name=tags.album_sort or tags.title_sort, - version=tags.version, - duration=int(tags.duration or 0), - provider_mappings={provider_mapping}, - ) - - # Set authors from writers or album artists or artists - audiobook.authors.set(tags.writers or tags.album_artists or tags.artists) - audiobook.metadata.genres = set(tags.genres) - audiobook.metadata.copyright = tags.get("copyright") - audiobook.metadata.description = tags.get("comment") - - if tags.musicbrainz_recordingid: - audiobook.mbid = tags.musicbrainz_recordingid - - # Handle chapters if present - if tags.chapters: - audiobook.metadata.chapters = [ - MediaItemChapter( - position=chapter.chapter_id, - name=chapter.title or f"Chapter {chapter.chapter_id}", - start=chapter.position_start, - end=chapter.position_end, - ) - for chapter in tags.chapters - ] - - return audiobook - - -def parse_podcast_episode_from_tags( - file_item: FileSystemItem, tags: AudioTags, instance_id: str, domain: str -) -> PodcastEpisode: - """Parse podcast episode from AudioTags.""" - podcast_name = tags.album or PurePosixPath(file_item.relative_path).parent.name - podcast_path = str(PurePosixPath(file_item.relative_path).parent) - provider_mapping = _create_provider_mapping(file_item, instance_id, domain, tags) - - episode = PodcastEpisode( - item_id=file_item.relative_path, - provider=instance_id, - name=tags.title, - sort_name=tags.title_sort, - provider_mappings={provider_mapping}, - position=tags.track or 0, - duration=int(tags.duration or 0), - podcast=Podcast( - item_id=podcast_path, - provider=instance_id, - name=podcast_name, - sort_name=tags.album_sort, - publisher=tags.get("publisher"), - total_episodes=0, # Set to 0 - will be updated by MA during sync - provider_mappings={ - ProviderMapping( - item_id=podcast_path, - provider_domain=domain, - provider_instance=instance_id, - ) - }, - ), - ) - - # Set episode metadata - episode.metadata.genres = set(tags.genres) - episode.metadata.copyright = tags.get("copyright") - episode.metadata.description = tags.get("comment") - - # Handle chapters if present - if tags.chapters: - episode.metadata.chapters = [ - MediaItemChapter( - position=chapter.chapter_id, - name=chapter.title or f"Chapter {chapter.chapter_id}", - start=chapter.position_start, - end=chapter.position_end, - ) - for chapter in tags.chapters - ] - - # Handle embedded cover image - if tags.has_cover_image: - episode.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=file_item.relative_path, - provider=instance_id, - remotely_accessible=False, - ) - ) - - # Copy (embedded) image from episode to podcast - assert isinstance(episode.podcast, Podcast) - if not episode.podcast.image and episode.image: - episode.podcast.metadata.add_image(episode.image) - - return episode diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index 865c53cccc..205abcdd1e 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -9,7 +9,7 @@ from urllib.parse import quote, unquote, urlparse, urlunparse import aiohttp -from music_assistant_models.enums import ImageType, MediaType, StreamType +from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType from music_assistant_models.errors import ( LoginFailed, MediaNotFoundError, @@ -40,7 +40,7 @@ DB_TABLE_PROVIDER_MAPPINGS, VERBOSE_LOG_LEVEL, ) -from music_assistant.helpers.tags import async_parse_tags +from music_assistant.helpers.tags import AudioTags, async_parse_tags from music_assistant.providers.filesystem_local import LocalFileSystemProvider from music_assistant.providers.filesystem_local.constants import CACHE_CATEGORY_AUDIOBOOK_CHAPTERS from music_assistant.providers.filesystem_local.helpers import FileSystemItem @@ -56,14 +56,8 @@ PODCAST_EPISODE_EXTENSIONS, SUPPORTED_EXTENSIONS, TRACK_EXTENSIONS, - WEBDAV_TIMEOUT, ) from .helpers import build_webdav_url, webdav_propfind, webdav_test_connection -from .parsers import ( - parse_audiobook_from_tags, - parse_podcast_episode_from_tags, - parse_track_from_tags, -) if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig @@ -88,7 +82,6 @@ def __init__( 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._session: aiohttp.ClientSession | None = None self.media_content_type = cast("str", config.get_value(CONF_CONTENT_TYPE)) self.sync_running: bool = False self.processed_audiobook_folders: set[str] = set() @@ -104,33 +97,21 @@ def instance_name_postfix(self) -> str | None: except Exception: return "WebDAV" - async def _get_session(self) -> aiohttp.ClientSession: - """Get or create HTTP session with proper authentication.""" - if self._session and not self._session.closed: - return self._session - - auth = None + @property + def _auth(self) -> aiohttp.BasicAuth | None: + """Get BasicAuth for WebDAV requests.""" if self.username: - auth = aiohttp.BasicAuth(self.username, self.password or "") - - connector = aiohttp.TCPConnector(ssl=self.verify_ssl) - - self._session = aiohttp.ClientSession( - auth=auth, - connector=connector, - timeout=aiohttp.ClientTimeout(total=WEBDAV_TIMEOUT), - ) - - return self._session + return aiohttp.BasicAuth(self.username, self.password or "") + return None async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" try: + session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl await webdav_test_connection( + session, self.base_url, - self.username, - self.password, - self.verify_ssl, + auth=self._auth, timeout=10, ) except (LoginFailed, SetupFailedError): @@ -140,12 +121,6 @@ async def handle_async_init(self) -> None: self.write_access = False - async def unload(self, is_removed: bool = False) -> None: - """Handle unload/close of the provider.""" - if self._session and not self._session.closed: - await self._session.close() - await super().unload(is_removed) - async def exists(self, file_path: str) -> bool: """Return bool if this WebDAV resource exists.""" if not file_path: @@ -153,8 +128,8 @@ async def exists(self, file_path: str) -> bool: try: webdav_url = build_webdav_url(self.base_url, file_path) - session = await self._get_session() - items = await webdav_propfind(session, webdav_url, depth=0) + 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 @@ -168,10 +143,10 @@ async def exists(self, file_path: str) -> bool: async def resolve(self, file_path: str) -> FileSystemItem: """Resolve WebDAV path to FileSystemItem.""" webdav_url = build_webdav_url(self.base_url, file_path) - session = await self._get_session() + 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) + 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( @@ -285,19 +260,9 @@ async def get_item_by_uri(self, uri: str) -> MediaItemType: async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" file_item = await self.resolve(prov_track_id) - - # Build authenticated URL for ffprobe - parsed = urlparse(file_item.absolute_path) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) - ) - else: - auth_url = file_item.absolute_path - + auth_url = self._build_authenticated_url(prov_track_id) tags = await async_parse_tags(auth_url, file_item.file_size) - return parse_track_from_tags(file_item, tags, self.instance_id, self.domain, self.config) + return await self._parse_track(file_item, tags, full_album_metadata=True) async def get_album(self, prov_album_id: str) -> Album: """Get album by id - reconstructed from tracks.""" @@ -318,38 +283,22 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: """Get full audiobook details by id.""" file_item = await self.resolve(prov_audiobook_id) - # If it's a folder (multi-file audiobook), get the first file if file_item.is_dir: items = await self._scandir(prov_audiobook_id) audiobook_files = [f for f in items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS] if not audiobook_files: raise MediaNotFoundError(f"No audiobook files found in folder: {prov_audiobook_id}") - # Use first file for metadata sorted_files = sorted(audiobook_files, key=lambda x: x.filename) file_item = sorted_files[0] file_path = file_item.relative_path else: - # Single file audiobook file_path = prov_audiobook_id - # Build authenticated URL for ffprobe - webdav_url = build_webdav_url(self.base_url, file_path) - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) - ) - else: - auth_url = webdav_url - + auth_url = self._build_authenticated_url(file_path) tags = await async_parse_tags(auth_url, file_item.file_size) - audiobook = parse_audiobook_from_tags( - file_item, tags, self.instance_id, self.domain, self.config - ) + audiobook = self._build_audiobook_from_tags(file_item, tags) # Use custom builder - # For multi-file audiobooks, override the item_id to be the folder if file_item.relative_path != prov_audiobook_id: audiobook.item_id = prov_audiobook_id @@ -357,35 +306,10 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: async def get_podcast(self, prov_podcast_id: str) -> Podcast: """Get full podcast details by id.""" - # For podcasts, the ID is the folder path containing episodes - # Scan for any episode file to get podcast metadata - items = await self._scandir(prov_podcast_id) - episode_files = [f for f in items if not f.is_dir and f.ext in PODCAST_EPISODE_EXTENSIONS] - - if not episode_files: - raise MediaNotFoundError(f"No podcast episodes found in: {prov_podcast_id}") - - # Parse first episode to get podcast metadata - first_episode_file = sorted(episode_files, key=lambda x: x.filename)[0] - - webdav_url = build_webdav_url(self.base_url, first_episode_file.relative_path) - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) - ) - else: - auth_url = webdav_url - - tags = await async_parse_tags(auth_url, first_episode_file.file_size) - episode = parse_podcast_episode_from_tags( - first_episode_file, tags, self.instance_id, self.domain - ) - - # Return the podcast object from the episode - assert isinstance(episode.podcast, Podcast) - return episode.podcast + async for episode in self.get_podcast_episodes(prov_podcast_id): + assert isinstance(episode.podcast, Podcast) + return episode.podcast + raise MediaNotFoundError(f"Podcast not found: {prov_podcast_id}") async def get_podcast_episodes( self, @@ -396,29 +320,30 @@ async def get_podcast_episodes( episode_files = [f for f in items if not f.is_dir and f.ext in PODCAST_EPISODE_EXTENSIONS] for episode_file in sorted(episode_files, key=lambda x: x.filename): - webdav_url = build_webdav_url(self.base_url, episode_file.relative_path) - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - ( - parsed.scheme, - netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment, - ) - ) - else: - auth_url = webdav_url - + auth_url = self._build_authenticated_url(episode_file.relative_path) tags = await async_parse_tags(auth_url, episode_file.file_size) - episode = parse_podcast_episode_from_tags( - episode_file, tags, self.instance_id, self.domain - ) + episode = self._build_podcast_episode_from_tags( + episode_file, tags + ) # Use custom builder yield episode + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """Get full podcast episode details by id.""" + file_item = await self.resolve(prov_episode_id) + auth_url = self._build_authenticated_url(prov_episode_id) + tags = await async_parse_tags(auth_url, file_item.file_size) + episode = self._build_podcast_episode_from_tags(file_item, tags) + + # Add folder images to podcast + podcast_folder = str(PurePosixPath(prov_episode_id).parent) + folder_images = await self._get_local_images(podcast_folder) + + assert isinstance(episode.podcast, Podcast) + if folder_images: + episode.podcast.metadata.images = folder_images + + return episode + async def sync_library(self, media_type: MediaType, import_as_favorite: bool = False) -> None: """Run library sync for WebDAV provider.""" if self.sync_running: @@ -462,82 +387,6 @@ async def sync_library(self, media_type: MediaType, import_as_favorite: bool = F self.sync_running = False self.logger.info(f"Completed library sync for WebDAV provider {self.name}") - async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Return the content details for the given media when it will be streamed.""" - if media_type == MediaType.TRACK: - return await self._get_track_stream_details(item_id) - elif media_type == MediaType.AUDIOBOOK: - return await self._get_audiobook_stream_details(item_id) - elif media_type == MediaType.PODCAST_EPISODE: - return await self._get_podcast_stream_details(item_id) - else: - raise NotImplementedError(f"Streaming not implemented for {media_type}") - - async def _get_track_stream_details(self, item_id: str) -> StreamDetails: - """Get stream details for a track.""" - track = await self.mass.music.tracks.get_library_item_by_prov_id(item_id, self.instance_id) - if track is None: - self.logger.debug(f"Track not in library, parsing from file: {item_id}") - track = await self.get_track(item_id) - - return await self._build_http_stream_details(item_id, track, MediaType.TRACK) - - async def _get_audiobook_stream_details(self, item_id: str) -> StreamDetails: - """Get stream details for an audiobook.""" - # Get or parse audiobook - audiobook = await self.mass.music.audiobooks.get_library_item_by_prov_id( - item_id, self.instance_id - ) - if audiobook is None: - audiobook = await self._parse_audiobook_from_file(item_id) - - # Get audio format - prov_mapping = next((x for x in audiobook.provider_mappings if x.item_id == item_id), None) - audio_format = prov_mapping.audio_format if prov_mapping else AudioFormat() - - # Check if multi-file audiobook - file_item = await self.resolve(item_id) - file_based_chapters = await self._get_audiobook_chapters_from_cache(item_id, file_item) - - if file_based_chapters: - return await self._build_multifile_audiobook_stream( - item_id, audiobook, audio_format, file_based_chapters - ) - - # Single-file audiobook - return await self._build_http_stream_details(item_id, audiobook, MediaType.AUDIOBOOK) - - async def _get_podcast_stream_details(self, item_id: str) -> StreamDetails: - """Get stream details for a podcast episode.""" - try: - file_item = await self.resolve(item_id) - auth_url = self._build_authenticated_url(item_id) - - tags = await async_parse_tags(auth_url, file_item.file_size) - episode = parse_podcast_episode_from_tags( - file_item, tags, self.instance_id, self.domain - ) - - return await self._build_http_stream_details( - item_id, episode, MediaType.PODCAST_EPISODE - ) - - except Exception as err: - self.logger.exception(f"Failed to process podcast episode {item_id}: {err}") - raise MediaNotFoundError(f"Failed to process podcast episode: {item_id}") from err - - async def _parse_audiobook_from_file(self, item_id: str) -> Audiobook: - """Parse audiobook from file when not in library.""" - file_item = await self.resolve(item_id) - auth_url = self._build_authenticated_url(item_id) - tags = await async_parse_tags(auth_url, file_item.file_size) - audiobook = await self._parse_audiobook(file_item, tags) - - if not audiobook: - raise MediaNotFoundError(f"Audiobook not found: {item_id}") - - return audiobook - async def _get_audiobook_chapters_from_cache( self, item_id: str, file_item: FileSystemItem ) -> list[tuple[str, float]] | None: @@ -565,68 +414,6 @@ async def _get_audiobook_chapters_from_cache( ) return cast("list[tuple[str, float]] | None", file_based_chapters) - async def _build_multifile_audiobook_stream( - self, - item_id: str, - audiobook: Audiobook, - audio_format: AudioFormat, - file_based_chapters: list[tuple[str, float]], - ) -> StreamDetails: - """Build stream details for multi-file audiobook.""" - chapter_urls = [] - for chapter_path, _ in file_based_chapters: - auth_url = self._build_authenticated_url(chapter_path) - chapter_urls.append(auth_url) - - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=audio_format, - media_type=MediaType.AUDIOBOOK, - stream_type=StreamType.CUSTOM, - duration=audiobook.duration, - data={ - "chapters": chapter_urls, - "chapters_data": file_based_chapters, - }, - allow_seek=True, - can_seek=True, - ) - - async def _build_http_stream_details( - self, - item_id: str, - library_item: Track | Audiobook | PodcastEpisode, - media_type: MediaType, - ) -> StreamDetails: - """Build stream details for single HTTP file.""" - auth_url = self._build_authenticated_url(item_id) - - prov_mapping = next( - (x for x in library_item.provider_mappings if x.item_id == item_id), None - ) - audio_format = prov_mapping.audio_format if prov_mapping else AudioFormat() - - file_size = None - try: - file_item = await self.resolve(item_id) - file_size = file_item.file_size - except Exception as err: - self.logger.debug(f"Could not get file size for {item_id}: {err}") - - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=audio_format, - media_type=media_type, - stream_type=StreamType.HTTP, - duration=library_item.duration, - size=file_size, - path=auth_url, - can_seek=True, - allow_seek=True, - ) - 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) @@ -642,47 +429,6 @@ def _build_authenticated_url(self, file_path: str) -> str: return webdav_url - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Get audio stream for WebDAV items.""" - if streamdetails.media_type == MediaType.AUDIOBOOK and isinstance(streamdetails.data, dict): - async for chunk in self._stream_multifile_audiobook(streamdetails, seek_position): - yield chunk - else: - raise NotImplementedError("Use HTTP stream type for single files") - - async def _stream_multifile_audiobook( - self, streamdetails: StreamDetails, seek_position: int - ) -> AsyncGenerator[bytes, None]: - """Stream multi-file audiobook chapters.""" - chapter_urls = streamdetails.data.get("chapters", []) - chapters_data = streamdetails.data.get("chapters_data", []) - - # Calculate starting chapter - start_chapter = self._calculate_start_chapter(chapters_data, seek_position) - - # Stream chapters - chapters_yielded = False - for i in range(start_chapter, len(chapter_urls)): - chapter_url = chapter_urls[i] - - try: - async with self.mass.http_session.get(chapter_url) as response: - response.raise_for_status() - async for chunk in response.content.iter_chunked(8192): - chapters_yielded = True - yield chunk - - except Exception as e: - self.logger.exception(f"Chapter {i + 1} streaming failed: {e}") - continue - - if not chapters_yielded: - raise MediaNotFoundError( - f"Failed to stream any chapters for audiobook {streamdetails.item_id}" - ) - def _calculate_start_chapter( self, chapters_data: list[tuple[str, float]], seek_position: int ) -> int: @@ -719,10 +465,10 @@ def _calculate_start_chapter( async def _scandir(self, path: str) -> list[FileSystemItem]: """List WebDAV directory contents.""" webdav_url = build_webdav_url(self.base_url, path) - session = await self._get_session() + 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) + webdav_items = await webdav_propfind(session, webdav_url, depth=1, auth=self._auth) filesystem_items: list[FileSystemItem] = [] for webdav_item in webdav_items: @@ -897,28 +643,9 @@ async def _process_webdav_item( async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) -> None: """Process a track item.""" try: - # Build full WebDAV URL from relative path - webdav_url = build_webdav_url(self.base_url, item.relative_path) - - # Add authentication to URL for ffprobe - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - ( - parsed.scheme, - netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment, - ) - ) - else: - auth_url = webdav_url - + auth_url = self._build_authenticated_url(item.relative_path) tags = await async_parse_tags(auth_url, item.file_size) - track = parse_track_from_tags(item, tags, self.instance_id, self.domain, self.config) + track = await self._parse_track(item, tags) # Add folder images to album if present if track.album and isinstance(track.album, Album): @@ -930,7 +657,6 @@ async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) # Add folder images to album artists for artist in track.album.artists: if isinstance(artist, Artist) and not artist.metadata.images: - # Try to get artist folder from provider mapping URL artist_mapping = next( ( m @@ -967,8 +693,7 @@ async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) await self.mass.music.tracks.add_item_to_library( track, overwrite_existing=prev_checksum is not None ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: - self.logger.error(f"Provider error processing WebDAV track {item.relative_path}: {err}") + except (LoginFailed, SetupFailedError, ProviderUnavailableError): raise except aiohttp.ClientError as err: self.logger.error( @@ -980,37 +705,13 @@ async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) async def _process_audiobook(self, item: FileSystemItem, prev_checksum: str | None) -> None: """Process an audiobook item.""" try: - # Build full WebDAV URL from relative path - webdav_url = build_webdav_url(self.base_url, item.relative_path) - - # Add authentication to URL for ffprobe - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - ( - parsed.scheme, - netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment, - ) - ) - else: - auth_url = webdav_url - + auth_url = self._build_authenticated_url(item.relative_path) tags = await async_parse_tags(auth_url, item.file_size) - audiobook = parse_audiobook_from_tags( - item, tags, self.instance_id, self.domain, self.config - ) + audiobook = self._build_audiobook_from_tags(item, tags) # Use custom builder await self.mass.music.audiobooks.add_item_to_library( audiobook, overwrite_existing=prev_checksum is not None ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: - self.logger.error( - f"Provider error processing WebDAV audiobook {item.relative_path}: {err}" - ) + except (LoginFailed, SetupFailedError, ProviderUnavailableError): raise except aiohttp.ClientError as err: self.logger.error( @@ -1027,34 +728,14 @@ async def _process_multifile_audiobook( ) -> None: """Process a multi-file audiobook folder.""" try: - # Sort files by name sorted_files = sorted(audiobook_files, key=lambda x: x.filename) - - # Parse first file to get audiobook metadata first_file = sorted_files[0] - webdav_url = build_webdav_url(self.base_url, first_file.relative_path) - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - ( - parsed.scheme, - netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment, - ) - ) - else: - auth_url = webdav_url + auth_url = self._build_authenticated_url(first_file.relative_path) tags = await async_parse_tags(auth_url, first_file.file_size) - # Create audiobook from folder - audiobook = parse_audiobook_from_tags( - first_file, tags, self.instance_id, self.domain, self.config - ) + # Use custom builder instead of parent's _parse_audiobook + audiobook = self._build_audiobook_from_tags(first_file, tags) # Override item_id to be the folder audiobook.item_id = folder_path @@ -1072,23 +753,7 @@ async def _process_multifile_audiobook( total_duration = 0.0 for idx, file_item in enumerate(sorted_files, start=1): - file_url = build_webdav_url(self.base_url, file_item.relative_path) - parsed = urlparse(file_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - file_auth_url = urlunparse( - ( - parsed.scheme, - netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment, - ) - ) - else: - file_auth_url = file_url - + file_auth_url = self._build_authenticated_url(file_item.relative_path) file_tags = await async_parse_tags(file_auth_url, file_item.file_size) duration = file_tags.duration or 0 @@ -1119,15 +784,11 @@ async def _process_multifile_audiobook( if folder_images: audiobook.metadata.images = folder_images - # Store audiobook await self.mass.music.audiobooks.add_item_to_library( audiobook, overwrite_existing=prev_checksum is not None ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: - self.logger.error( - f"Provider error processing multi-file audiobook {folder_path}: {err}" - ) + except (LoginFailed, SetupFailedError, ProviderUnavailableError): raise except aiohttp.ClientError as err: self.logger.error( @@ -1139,40 +800,21 @@ async def _process_multifile_audiobook( async def _process_podcast(self, item: FileSystemItem, prev_checksum: str | None) -> None: """Process a podcast episode item.""" try: - # Build full WebDAV URL from relative path - webdav_url = build_webdav_url(self.base_url, item.relative_path) - - # Add authentication to URL for ffprobe - parsed = urlparse(webdav_url) - if self.username and self.password: - netloc = f"{self.username}:{self.password}@{parsed.netloc}" - auth_url = urlunparse( - ( - parsed.scheme, - netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment, - ) - ) - else: - auth_url = webdav_url - + auth_url = self._build_authenticated_url(item.relative_path) tags = await async_parse_tags(auth_url, item.file_size) - episode = parse_podcast_episode_from_tags(item, tags, self.instance_id, self.domain) + episode = self._build_podcast_episode_from_tags(item, tags) # Use custom builder + podcast_folder = str(PurePosixPath(item.relative_path).parent) folder_images = await self._get_local_images(podcast_folder) + assert isinstance(episode.podcast, Podcast) if folder_images: episode.podcast.metadata.images = folder_images + await self.mass.music.podcasts.add_item_to_library( episode.podcast, overwrite_existing=prev_checksum is not None ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError) as err: - self.logger.error( - f"Provider error processing WebDAV podcast {item.relative_path}: {err}" - ) + except (LoginFailed, SetupFailedError, ProviderUnavailableError): raise except aiohttp.ClientError as err: self.logger.error( @@ -1222,12 +864,286 @@ async def _get_local_images( async def resolve_image(self, path: str) -> str | bytes: """Resolve image path to actual image data or URL.""" - # Build WebDAV 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 - # Fetch the image data with authentication - session = await self._get_session() - async with session.get(webdav_url) as resp: + 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 _get_stream_details_for_track(self, item_id: str) -> StreamDetails: + """Return the streamdetails for a track/song.""" + library_item = await self.mass.music.tracks.get_library_item_by_prov_id( + item_id, self.instance_id + ) + if library_item is None: + file_item = await self.resolve(item_id) + auth_url = self._build_authenticated_url(item_id) + tags = await async_parse_tags(auth_url, file_item.file_size) + library_item = await self._parse_track(file_item, tags) + + prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) + file_item = await self.resolve(item_id) + auth_url = self._build_authenticated_url(item_id) + + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=prov_mapping.audio_format, + media_type=MediaType.TRACK, + stream_type=StreamType.HTTP, # CRITICAL: HTTP not LOCAL_FILE + duration=library_item.duration, + size=file_item.file_size, + path=auth_url, # CRITICAL: authenticated URL + can_seek=True, + allow_seek=True, + ) + + async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails: + """Return the streamdetails for a podcast episode.""" + file_item = await self.resolve(item_id) + auth_url = self._build_authenticated_url(item_id) + tags = await async_parse_tags(auth_url, file_item.file_size) + + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(file_item.ext or tags.format), + sample_rate=tags.sample_rate, + bit_depth=tags.bits_per_sample, + channels=tags.channels, + bit_rate=tags.bit_rate, + ), + media_type=MediaType.PODCAST_EPISODE, + stream_type=StreamType.HTTP, + duration=int(tags.duration or 0), + size=file_item.file_size, + path=auth_url, + allow_seek=True, + can_seek=True, + ) + + async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails: + """Return the streamdetails for an audiobook.""" + library_item = await self.mass.music.audiobooks.get_library_item_by_prov_id( + item_id, self.instance_id + ) + if library_item is None: + file_item = await self.resolve(item_id) + auth_url = self._build_authenticated_url(item_id) + tags = await async_parse_tags(auth_url, file_item.file_size) + library_item = self._build_audiobook_from_tags(file_item, tags) + + prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) + file_item = await self.resolve(item_id) + + # Check for multi-file audiobook + file_based_chapters = await self._get_audiobook_chapters_from_cache(item_id, file_item) + + if file_based_chapters: + # Multi-file audiobook - use CUSTOM stream type + chapter_urls = [self._build_authenticated_url(ch[0]) for ch in file_based_chapters] + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=prov_mapping.audio_format, + media_type=MediaType.AUDIOBOOK, + stream_type=StreamType.CUSTOM, # CUSTOM not MULTI_FILE + duration=library_item.duration, + data={ # data dict, not path list + "chapters": chapter_urls, + "chapters_data": file_based_chapters, + }, + allow_seek=True, + can_seek=True, + ) + + # Single file audiobook + auth_url = self._build_authenticated_url(item_id) + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=prov_mapping.audio_format, + media_type=MediaType.AUDIOBOOK, + stream_type=StreamType.HTTP, + duration=library_item.duration, + size=file_item.file_size, + path=auth_url, + can_seek=True, + allow_seek=True, + ) + + def _build_audiobook_from_tags(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook: + """Build audiobook from tags without filesystem operations.""" + book_name = tags.album or tags.title + + audiobook = Audiobook( + item_id=file_item.relative_path, + provider=self.instance_id, + name=book_name, + sort_name=tags.album_sort or tags.title_sort, + version=tags.version, + duration=int(tags.duration or 0), + provider_mappings={ + ProviderMapping( + item_id=file_item.relative_path, + provider_domain=self.domain, + provider_instance=self.instance_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(file_item.ext or tags.format), + sample_rate=tags.sample_rate, + bit_depth=tags.bits_per_sample, + channels=tags.channels, + bit_rate=tags.bit_rate, + ), + details=file_item.checksum, + ) + }, + ) + + # Set authors and metadata + audiobook.authors.set(tags.writers or tags.album_artists or tags.artists) + audiobook.metadata.genres = set(tags.genres) + audiobook.metadata.copyright = tags.get("copyright") + audiobook.metadata.description = tags.get("comment") + + if tags.musicbrainz_recordingid: + audiobook.mbid = tags.musicbrainz_recordingid + + # Handle embedded chapters + if tags.chapters: + audiobook.metadata.chapters = [ + MediaItemChapter( + position=chapter.chapter_id, + name=chapter.title or f"Chapter {chapter.chapter_id}", + start=chapter.position_start, + end=chapter.position_end, + ) + for chapter in tags.chapters + ] + + # Handle embedded cover image + if tags.has_cover_image: + audiobook.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=file_item.relative_path, + provider=self.instance_id, + remotely_accessible=False, + ) + ) + + return audiobook + + def _build_podcast_episode_from_tags( + self, file_item: FileSystemItem, tags: AudioTags + ) -> PodcastEpisode: + """Build podcast episode from tags without filesystem operations.""" + podcast_name = tags.album or PurePosixPath(file_item.relative_path).parent.name + podcast_path = str(PurePosixPath(file_item.relative_path).parent) + + episode = PodcastEpisode( + item_id=file_item.relative_path, + provider=self.instance_id, + name=tags.title, + sort_name=tags.title_sort, + provider_mappings={ + ProviderMapping( + item_id=file_item.relative_path, + provider_domain=self.domain, + provider_instance=self.instance_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(file_item.ext or tags.format), + sample_rate=tags.sample_rate, + bit_depth=tags.bits_per_sample, + channels=tags.channels, + bit_rate=tags.bit_rate, + ), + details=file_item.checksum, + ) + }, + position=tags.track or 0, + duration=int(tags.duration or 0), + podcast=Podcast( + item_id=podcast_path, + provider=self.instance_id, + name=podcast_name, + sort_name=tags.album_sort, + publisher=tags.get("publisher"), + total_episodes=0, + provider_mappings={ + ProviderMapping( + item_id=podcast_path, + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, + ), + ) + + # Set metadata + episode.metadata.genres = set(tags.genres) + episode.metadata.copyright = tags.get("copyright") + episode.metadata.description = tags.get("comment") + + # Handle chapters + if tags.chapters: + episode.metadata.chapters = [ + MediaItemChapter( + position=chapter.chapter_id, + name=chapter.title or f"Chapter {chapter.chapter_id}", + start=chapter.position_start, + end=chapter.position_end, + ) + for chapter in tags.chapters + ] + + # Handle embedded cover + if tags.has_cover_image: + episode.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=file_item.relative_path, + provider=self.instance_id, + remotely_accessible=False, + ) + ) + + return episode + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Get audio stream for WebDAV items.""" + if streamdetails.media_type == MediaType.AUDIOBOOK and isinstance(streamdetails.data, dict): + async for chunk in self._stream_multifile_audiobook(streamdetails, seek_position): + yield chunk + else: + raise NotImplementedError("Use HTTP stream type for single files") + + async def _stream_multifile_audiobook( + self, streamdetails: StreamDetails, seek_position: int + ) -> AsyncGenerator[bytes, None]: + """Stream multi-file audiobook chapters.""" + chapter_urls = streamdetails.data.get("chapters", []) + chapters_data = streamdetails.data.get("chapters_data", []) + start_chapter = self._calculate_start_chapter(chapters_data, seek_position) + + chapters_yielded = False + for i in range(start_chapter, len(chapter_urls)): + try: + async with self.mass.http_session.get(chapter_urls[i]) as response: + response.raise_for_status() + async for chunk in response.content.iter_chunked(8192): + chapters_yielded = True + yield chunk + except Exception as e: + self.logger.exception(f"Chapter {i + 1} streaming failed: {e}") + continue + + if not chapters_yielded: + raise MediaNotFoundError( + f"Failed to stream any chapters for audiobook {streamdetails.item_id}" + ) From 6bfceaa4c64866d70a3618432f51bbb83538982e Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 6 Oct 2025 18:41:12 +1000 Subject: [PATCH 03/10] Remove redundant methods --- music_assistant/providers/webdav/provider.py | 51 +++----------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index 205abcdd1e..2304a734c6 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -179,13 +179,10 @@ def get_absolute_path(self, file_path: str) -> str: async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: """Browse this provider's items.""" - if self.media_content_type == "podcasts": - return await self.mass.music.podcasts.library_items(provider=self.instance_id) - if self.media_content_type == "audiobooks": - return await self.mass.music.audiobooks.library_items(provider=self.instance_id) + item_path = path.split("://", 1)[1] if "://" in path else "" + # For all paths (including subpaths for audiobooks/podcasts), browse actual folders items: list[MediaItemType | ItemMapping | BrowseFolder] = [] - item_path = path.split("://", 1)[1] if "://" in path else "" try: filesystem_items = await self._scandir(item_path) @@ -227,36 +224,6 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow return items - async def get_item_by_uri(self, uri: str) -> MediaItemType: - """Get a single media item by URI.""" - item_path = uri.split("://", 1)[1] if "://" in uri else uri - file_item = await self.resolve(item_path) - - # Folders aren't returned by get_item_by_uri - MA handles them differently - if file_item.is_dir: - raise MediaNotFoundError(f"Cannot get media item for directory: {item_path}") - - # Determine type from extension and return appropriate item - ext = ( - PurePosixPath(file_item.filename).suffix.lstrip(".").lower() - if "." in file_item.filename - else None - ) - - if ext in TRACK_EXTENSIONS: - return await self.get_track(item_path) - - if ext in PLAYLIST_EXTENSIONS: - raise NotImplementedError("Playlist retrieval not yet implemented") - - if ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks": - raise NotImplementedError("Audiobook retrieval not yet implemented") - - if ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts": - raise NotImplementedError("Podcast episode retrieval not yet implemented") - - raise MediaNotFoundError(f"Unsupported file type: {file_item.filename}") - async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" file_item = await self.resolve(prov_track_id) @@ -304,13 +271,6 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: return audiobook - async def get_podcast(self, prov_podcast_id: str) -> Podcast: - """Get full podcast details by id.""" - async for episode in self.get_podcast_episodes(prov_podcast_id): - assert isinstance(episode.podcast, Podcast) - return episode.podcast - raise MediaNotFoundError(f"Podcast not found: {prov_podcast_id}") - async def get_podcast_episodes( self, prov_podcast_id: str, @@ -1131,10 +1091,15 @@ async def _stream_multifile_audiobook( chapters_data = streamdetails.data.get("chapters_data", []) start_chapter = self._calculate_start_chapter(chapters_data, seek_position) + # Use session with appropriate SSL setting and longer timeout for streaming + session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl + # Set a longer timeout for reading large audiobook files + timeout = aiohttp.ClientTimeout(total=0, sock_read=5 * 60) # 5 minute read timeout + chapters_yielded = False for i in range(start_chapter, len(chapter_urls)): try: - async with self.mass.http_session.get(chapter_urls[i]) as response: + async with session.get(chapter_urls[i], timeout=timeout) as response: response.raise_for_status() async for chunk in response.content.iter_chunked(8192): chapters_yielded = True From 7a69aea118f40267097985def555d48ff7ffc567 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 6 Oct 2025 21:04:34 +1000 Subject: [PATCH 04/10] use multipartpath --- music_assistant/providers/webdav/provider.py | 92 ++------------------ 1 file changed, 9 insertions(+), 83 deletions(-) diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index 2304a734c6..ed6817d522 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -32,7 +32,7 @@ Track, UniqueList, ) -from music_assistant_models.streamdetails import StreamDetails +from music_assistant_models.streamdetails import MultiPartPath, StreamDetails from music_assistant.constants import ( CONF_PASSWORD, @@ -389,39 +389,6 @@ def _build_authenticated_url(self, file_path: str) -> str: return webdav_url - def _calculate_start_chapter( - self, chapters_data: list[tuple[str, float]], seek_position: int - ) -> int: - """Calculate which chapter to start from based on seek position.""" - # Define a small tolerance margin (e.g., 100 milliseconds) - # This handles cases where seek_position is slightly less than the chapter's start time - tolerance_seconds = 2.0 - - if seek_position <= 0: - return 0 - - accumulated_duration = 0.0 - - for i, (_, chapter_duration) in enumerate(chapters_data): - chapter_end_time = accumulated_duration + chapter_duration - - # If the seek is within TOLERANCE of the next chapter's start time, - # treat it as the next chapter's start time. - - # This is the time when chapter i ends, and chapter i+1 begins. - - if seek_position >= chapter_end_time - tolerance_seconds: - # If seek_position is at or just before the chapter end time (within tolerance), - # we treat it as seeking to the NEXT chapter. - accumulated_duration = chapter_end_time - continue - - # Otherwise, the seek position is clearly within the bounds of chapter i. - return i - - # Seek position beyond total duration - start from last chapter - return max(0, len(chapters_data) - 1) - async def _scandir(self, path: str) -> list[FileSystemItem]: """List WebDAV directory contents.""" webdav_url = build_webdav_url(self.base_url, path) @@ -899,25 +866,24 @@ async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) file_item = await self.resolve(item_id) - # Check for multi-file audiobook + # Check for multi-file audiobook chapters file_based_chapters = await self._get_audiobook_chapters_from_cache(item_id, file_item) if file_based_chapters: - # Multi-file audiobook - use CUSTOM stream type - chapter_urls = [self._build_authenticated_url(ch[0]) for ch in file_based_chapters] + # Multi-file audiobook - use HTTP with MultiPartPath list + chapter_paths = [ + MultiPartPath(path=self._build_authenticated_url(ch[0]), duration=ch[1]) + for ch in file_based_chapters + ] return StreamDetails( provider=self.instance_id, item_id=item_id, audio_format=prov_mapping.audio_format, media_type=MediaType.AUDIOBOOK, - stream_type=StreamType.CUSTOM, # CUSTOM not MULTI_FILE + stream_type=StreamType.HTTP, duration=library_item.duration, - data={ # data dict, not path list - "chapters": chapter_urls, - "chapters_data": file_based_chapters, - }, + path=chapter_paths, allow_seek=True, - can_seek=True, ) # Single file audiobook @@ -1072,43 +1038,3 @@ def _build_podcast_episode_from_tags( ) return episode - - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Get audio stream for WebDAV items.""" - if streamdetails.media_type == MediaType.AUDIOBOOK and isinstance(streamdetails.data, dict): - async for chunk in self._stream_multifile_audiobook(streamdetails, seek_position): - yield chunk - else: - raise NotImplementedError("Use HTTP stream type for single files") - - async def _stream_multifile_audiobook( - self, streamdetails: StreamDetails, seek_position: int - ) -> AsyncGenerator[bytes, None]: - """Stream multi-file audiobook chapters.""" - chapter_urls = streamdetails.data.get("chapters", []) - chapters_data = streamdetails.data.get("chapters_data", []) - start_chapter = self._calculate_start_chapter(chapters_data, seek_position) - - # Use session with appropriate SSL setting and longer timeout for streaming - session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl - # Set a longer timeout for reading large audiobook files - timeout = aiohttp.ClientTimeout(total=0, sock_read=5 * 60) # 5 minute read timeout - - chapters_yielded = False - for i in range(start_chapter, len(chapter_urls)): - try: - async with session.get(chapter_urls[i], timeout=timeout) as response: - response.raise_for_status() - async for chunk in response.content.iter_chunked(8192): - chapters_yielded = True - yield chunk - except Exception as e: - self.logger.exception(f"Chapter {i + 1} streaming failed: {e}") - continue - - if not chapters_yielded: - raise MediaNotFoundError( - f"Failed to stream any chapters for audiobook {streamdetails.item_id}" - ) From 00b92df338d89f92238c0e6182ed2105253d9a98 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 6 Oct 2025 23:38:26 +1000 Subject: [PATCH 05/10] Remove more redundancy --- music_assistant/providers/webdav/__init__.py | 25 +--- music_assistant/providers/webdav/constants.py | 57 +------- music_assistant/providers/webdav/provider.py | 128 ++++++------------ 3 files changed, 50 insertions(+), 160 deletions(-) diff --git a/music_assistant/providers/webdav/__init__.py b/music_assistant/providers/webdav/__init__.py index f3c98799ea..fb93feec87 100644 --- a/music_assistant/providers/webdav/__init__.py +++ b/music_assistant/providers/webdav/__init__.py @@ -8,6 +8,10 @@ from music_assistant_models.enums import ConfigEntryType, ProviderFeature from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME +from music_assistant.providers.filesystem_local.constants import ( + CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS, + CONF_ENTRY_MISSING_ALBUM_ARTIST, +) from .constants import CONF_CONTENT_TYPE, CONF_URL, CONF_VERIFY_SSL from .provider import WebDAVFileSystemProvider @@ -86,23 +90,6 @@ async def get_config_entries( 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", - ), + CONF_ENTRY_MISSING_ALBUM_ARTIST, + CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS, ) diff --git a/music_assistant/providers/webdav/constants.py b/music_assistant/providers/webdav/constants.py index da193f9b44..2b7e71a4a8 100644 --- a/music_assistant/providers/webdav/constants.py +++ b/music_assistant/providers/webdav/constants.py @@ -1,54 +1,9 @@ -"""Constants for WebDAV filesystem provider.""" - -from __future__ import annotations +"""WebDAV File System Provider constants.""" from typing import Final -# Config keys -CONF_URL = "url" -CONF_VERIFY_SSL = "verify_ssl" -CONF_CONTENT_TYPE = "content_type" - -# File extensions - reuse from filesystem_local patterns -TRACK_EXTENSIONS: Final[tuple[str, ...]] = ( - "mp3", - "flac", - "wav", - "m4a", - "aac", - "ogg", - "wma", - "opus", - "mp4", - "m4p", -) - -PLAYLIST_EXTENSIONS: Final[tuple[str, ...]] = ("m3u", "m3u8", "pls") - -AUDIOBOOK_EXTENSIONS: Final[tuple[str, ...]] = ( - "mp3", - "flac", - "m4a", - "m4b", - "aac", - "ogg", - "opus", - "wav", -) - -PODCAST_EPISODE_EXTENSIONS: Final[tuple[str, ...]] = TRACK_EXTENSIONS - -IMAGE_EXTENSIONS: Final[tuple[str, ...]] = ("jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff") - -SUPPORTED_EXTENSIONS: Final[tuple[str, ...]] = ( - *TRACK_EXTENSIONS, - *PLAYLIST_EXTENSIONS, - *AUDIOBOOK_EXTENSIONS, - *IMAGE_EXTENSIONS, -) - -# WebDAV specific constants -WEBDAV_TIMEOUT: Final[int] = 30 - -# Concurrent processing limit -MAX_CONCURRENT_TASKS: Final[int] = 3 +# 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 diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index ed6817d522..afbb5bf275 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -29,7 +29,6 @@ Podcast, PodcastEpisode, ProviderMapping, - Track, UniqueList, ) from music_assistant_models.streamdetails import MultiPartPath, StreamDetails @@ -42,20 +41,22 @@ ) from music_assistant.helpers.tags import AudioTags, async_parse_tags from music_assistant.providers.filesystem_local import LocalFileSystemProvider -from music_assistant.providers.filesystem_local.constants import CACHE_CATEGORY_AUDIOBOOK_CHAPTERS +from music_assistant.providers.filesystem_local.constants import ( + AUDIOBOOK_EXTENSIONS, + CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, + IMAGE_EXTENSIONS, + PLAYLIST_EXTENSIONS, + PODCAST_EPISODE_EXTENSIONS, + SUPPORTED_EXTENSIONS, + TRACK_EXTENSIONS, +) from music_assistant.providers.filesystem_local.helpers import FileSystemItem from .constants import ( - AUDIOBOOK_EXTENSIONS, CONF_CONTENT_TYPE, CONF_URL, CONF_VERIFY_SSL, - IMAGE_EXTENSIONS, MAX_CONCURRENT_TASKS, - PLAYLIST_EXTENSIONS, - PODCAST_EPISODE_EXTENSIONS, - SUPPORTED_EXTENSIONS, - TRACK_EXTENSIONS, ) from .helpers import build_webdav_url, webdav_propfind, webdav_test_connection @@ -94,8 +95,8 @@ def instance_name_postfix(self) -> str | None: if parsed.path and parsed.path != "/": return PurePosixPath(parsed.path).name return parsed.netloc - except Exception: - return "WebDAV" + except (ValueError, TypeError): + return "Invalid URL" @property def _auth(self) -> aiohttp.BasicAuth | None: @@ -152,7 +153,7 @@ async def resolve(self, file_path: str) -> FileSystemItem: return FileSystemItem( filename="", relative_path="", - absolute_path=webdav_url, + absolute_path=self._build_authenticated_url(file_path), # With auth is_dir=True, ) raise MediaNotFoundError(f"WebDAV resource not found: {file_path}") @@ -162,7 +163,7 @@ async def resolve(self, file_path: str) -> FileSystemItem: return FileSystemItem( filename=PurePosixPath(file_path).name or webdav_item.name, relative_path=file_path, - absolute_path=webdav_url, + absolute_path=self._build_authenticated_url(file_path), # With auth is_dir=webdav_item.is_dir, checksum=webdav_item.last_modified or "unknown", file_size=webdav_item.size, @@ -170,7 +171,9 @@ async def resolve(self, file_path: str) -> FileSystemItem: except MediaNotFoundError: raise - except Exception as err: + except (LoginFailed, SetupFailedError): + raise + except aiohttp.ClientError as err: raise MediaNotFoundError(f"Failed to resolve WebDAV path {file_path}: {err}") from err def get_absolute_path(self, file_path: str) -> str: @@ -224,28 +227,6 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow return items - async def get_track(self, prov_track_id: str) -> Track: - """Get full track details by id.""" - file_item = await self.resolve(prov_track_id) - auth_url = self._build_authenticated_url(prov_track_id) - tags = await async_parse_tags(auth_url, file_item.file_size) - return await self._parse_track(file_item, tags, full_album_metadata=True) - - async def get_album(self, prov_album_id: str) -> Album: - """Get album by id - reconstructed from tracks.""" - items = await self._scandir(prov_album_id) - for item in items: - if item.ext in TRACK_EXTENSIONS: - track = await self.get_track(item.relative_path) - if track.album and isinstance(track.album, Album): - album = track.album - # Scan for folder images on every get_album call - folder_images = await self._get_local_images(prov_album_id) - if folder_images: - album.metadata.images = folder_images - return album - raise MediaNotFoundError(f"No tracks found in album path: {prov_album_id}") - async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: """Get full audiobook details by id.""" file_item = await self.resolve(prov_audiobook_id) @@ -258,13 +239,11 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: sorted_files = sorted(audiobook_files, key=lambda x: x.filename) file_item = sorted_files[0] - file_path = file_item.relative_path - else: - file_path = prov_audiobook_id - auth_url = self._build_authenticated_url(file_path) - tags = await async_parse_tags(auth_url, file_item.file_size) - audiobook = self._build_audiobook_from_tags(file_item, tags) # Use custom builder + tags = await async_parse_tags( + file_item.absolute_path, file_item.file_size + ) # ← Use absolute_path + audiobook = self._build_audiobook_from_tags(file_item, tags) if file_item.relative_path != prov_audiobook_id: audiobook.item_id = prov_audiobook_id @@ -280,30 +259,10 @@ async def get_podcast_episodes( episode_files = [f for f in items if not f.is_dir and f.ext in PODCAST_EPISODE_EXTENSIONS] for episode_file in sorted(episode_files, key=lambda x: x.filename): - auth_url = self._build_authenticated_url(episode_file.relative_path) - tags = await async_parse_tags(auth_url, episode_file.file_size) - episode = self._build_podcast_episode_from_tags( - episode_file, tags - ) # Use custom builder + tags = await async_parse_tags(episode_file.absolute_path, episode_file.file_size) + episode = self._build_podcast_episode_from_tags(episode_file, tags) yield episode - async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: - """Get full podcast episode details by id.""" - file_item = await self.resolve(prov_episode_id) - auth_url = self._build_authenticated_url(prov_episode_id) - tags = await async_parse_tags(auth_url, file_item.file_size) - episode = self._build_podcast_episode_from_tags(file_item, tags) - - # Add folder images to podcast - podcast_folder = str(PurePosixPath(prov_episode_id).parent) - folder_images = await self._get_local_images(podcast_folder) - - assert isinstance(episode.podcast, Podcast) - if folder_images: - episode.podcast.metadata.images = folder_images - - return episode - async def sync_library(self, media_type: MediaType, import_as_favorite: bool = False) -> None: """Run library sync for WebDAV provider.""" if self.sync_running: @@ -427,7 +386,7 @@ async def _scandir(self, path: str) -> list[FileSystemItem]: FileSystemItem( filename=decoded_name, relative_path=relative_path, - absolute_path=webdav_item.href, + 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, @@ -570,8 +529,7 @@ async def _process_webdav_item( async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) -> None: """Process a track item.""" try: - auth_url = self._build_authenticated_url(item.relative_path) - tags = await async_parse_tags(auth_url, item.file_size) + tags = await async_parse_tags(item.absolute_path, item.file_size) track = await self._parse_track(item, tags) # Add folder images to album if present @@ -632,8 +590,7 @@ async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) async def _process_audiobook(self, item: FileSystemItem, prev_checksum: str | None) -> None: """Process an audiobook item.""" try: - auth_url = self._build_authenticated_url(item.relative_path) - tags = await async_parse_tags(auth_url, item.file_size) + tags = await async_parse_tags(item.absolute_path, item.file_size) audiobook = self._build_audiobook_from_tags(item, tags) # Use custom builder await self.mass.music.audiobooks.add_item_to_library( audiobook, overwrite_existing=prev_checksum is not None @@ -658,8 +615,7 @@ async def _process_multifile_audiobook( sorted_files = sorted(audiobook_files, key=lambda x: x.filename) first_file = sorted_files[0] - auth_url = self._build_authenticated_url(first_file.relative_path) - tags = await async_parse_tags(auth_url, first_file.file_size) + tags = await async_parse_tags(first_file.absolute_path, first_file.file_size) # Use custom builder instead of parent's _parse_audiobook audiobook = self._build_audiobook_from_tags(first_file, tags) @@ -680,8 +636,7 @@ async def _process_multifile_audiobook( total_duration = 0.0 for idx, file_item in enumerate(sorted_files, start=1): - file_auth_url = self._build_authenticated_url(file_item.relative_path) - file_tags = await async_parse_tags(file_auth_url, file_item.file_size) + file_tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) duration = file_tags.duration or 0 chapters.append( @@ -692,7 +647,7 @@ async def _process_multifile_audiobook( end=total_duration + duration, ) ) - chapter_files.append((file_item.relative_path, duration)) + chapter_files.append((file_item.absolute_path, duration)) total_duration += duration audiobook.duration = int(total_duration) @@ -727,8 +682,7 @@ async def _process_multifile_audiobook( async def _process_podcast(self, item: FileSystemItem, prev_checksum: str | None) -> None: """Process a podcast episode item.""" try: - auth_url = self._build_authenticated_url(item.relative_path) - tags = await async_parse_tags(auth_url, item.file_size) + tags = await async_parse_tags(item.absolute_path, item.file_size) episode = self._build_podcast_episode_from_tags(item, tags) # Use custom builder podcast_folder = str(PurePosixPath(item.relative_path).parent) @@ -806,23 +760,21 @@ async def _get_stream_details_for_track(self, item_id: str) -> StreamDetails: ) if library_item is None: file_item = await self.resolve(item_id) - auth_url = self._build_authenticated_url(item_id) - tags = await async_parse_tags(auth_url, file_item.file_size) + tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) library_item = await self._parse_track(file_item, tags) prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) file_item = await self.resolve(item_id) - auth_url = self._build_authenticated_url(item_id) return StreamDetails( provider=self.instance_id, item_id=item_id, audio_format=prov_mapping.audio_format, media_type=MediaType.TRACK, - stream_type=StreamType.HTTP, # CRITICAL: HTTP not LOCAL_FILE + stream_type=StreamType.HTTP, duration=library_item.duration, size=file_item.file_size, - path=auth_url, # CRITICAL: authenticated URL + path=file_item.absolute_path, # NEW: use absolute_path instead of auth_url can_seek=True, allow_seek=True, ) @@ -830,8 +782,7 @@ async def _get_stream_details_for_track(self, item_id: str) -> StreamDetails: async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails: """Return the streamdetails for a podcast episode.""" file_item = await self.resolve(item_id) - auth_url = self._build_authenticated_url(item_id) - tags = await async_parse_tags(auth_url, file_item.file_size) + tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) return StreamDetails( provider=self.instance_id, @@ -847,7 +798,7 @@ async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamD stream_type=StreamType.HTTP, duration=int(tags.duration or 0), size=file_item.file_size, - path=auth_url, + path=file_item.absolute_path, allow_seek=True, can_seek=True, ) @@ -859,8 +810,7 @@ async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails ) if library_item is None: file_item = await self.resolve(item_id) - auth_url = self._build_authenticated_url(item_id) - tags = await async_parse_tags(auth_url, file_item.file_size) + tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) library_item = self._build_audiobook_from_tags(file_item, tags) prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) @@ -870,10 +820,9 @@ async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails file_based_chapters = await self._get_audiobook_chapters_from_cache(item_id, file_item) if file_based_chapters: - # Multi-file audiobook - use HTTP with MultiPartPath list + # Multi-file audiobook - chapters already have authenticated URLs chapter_paths = [ - MultiPartPath(path=self._build_authenticated_url(ch[0]), duration=ch[1]) - for ch in file_based_chapters + MultiPartPath(path=ch[0], duration=ch[1]) for ch in file_based_chapters ] return StreamDetails( provider=self.instance_id, @@ -887,7 +836,6 @@ async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails ) # Single file audiobook - auth_url = self._build_authenticated_url(item_id) return StreamDetails( provider=self.instance_id, item_id=item_id, @@ -896,7 +844,7 @@ async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails stream_type=StreamType.HTTP, duration=library_item.duration, size=file_item.file_size, - path=auth_url, + path=file_item.absolute_path, can_seek=True, allow_seek=True, ) From c2dc2ae0c547796b7762f6386d03c5613b62f4bb Mon Sep 17 00:00:00 2001 From: Gav Date: Tue, 7 Oct 2025 01:51:34 +1000 Subject: [PATCH 06/10] Refactor to reduce duplication --- music_assistant/providers/webdav/__init__.py | 25 +- music_assistant/providers/webdav/constants.py | 1 + music_assistant/providers/webdav/helpers.py | 30 +- music_assistant/providers/webdav/provider.py | 914 ++++-------------- 4 files changed, 220 insertions(+), 750 deletions(-) diff --git a/music_assistant/providers/webdav/__init__.py b/music_assistant/providers/webdav/__init__.py index fb93feec87..f3c98799ea 100644 --- a/music_assistant/providers/webdav/__init__.py +++ b/music_assistant/providers/webdav/__init__.py @@ -8,10 +8,6 @@ from music_assistant_models.enums import ConfigEntryType, ProviderFeature from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME -from music_assistant.providers.filesystem_local.constants import ( - CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS, - CONF_ENTRY_MISSING_ALBUM_ARTIST, -) from .constants import CONF_CONTENT_TYPE, CONF_URL, CONF_VERIFY_SSL from .provider import WebDAVFileSystemProvider @@ -90,6 +86,23 @@ async def get_config_entries( default_value=False, description="Verify SSL certificates when connecting to HTTPS WebDAV servers", ), - CONF_ENTRY_MISSING_ALBUM_ARTIST, - CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS, + 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 index 2b7e71a4a8..9487ef23eb 100644 --- a/music_assistant/providers/webdav/constants.py +++ b/music_assistant/providers/webdav/constants.py @@ -7,3 +7,4 @@ 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 index c9a76d35ee..74f1fd10ba 100644 --- a/music_assistant/providers/webdav/helpers.py +++ b/music_assistant/providers/webdav/helpers.py @@ -175,13 +175,35 @@ def _parse_propfind_response(response_text: str, base_url: str) -> list[WebDAVIt async def webdav_test_connection( - session: aiohttp.ClientSession, # Pass session in instead of creating - url: str, - auth: aiohttp.BasicAuth | None = None, + base_url: str, + username: str | None, + password: str | None, + verify_ssl: bool, timeout: int = 10, ) -> None: """Test WebDAV connection and authentication.""" - await webdav_propfind(session, url, depth=0, timeout=timeout, auth=auth) + auth = aiohttp.BasicAuth(username, password) if username 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: diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index afbb5bf275..ba6cc0c266 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -3,13 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Sequence +from collections.abc import Sequence from pathlib import PurePosixPath from typing import TYPE_CHECKING, cast from urllib.parse import quote, unquote, urlparse, urlunparse import aiohttp -from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType +from music_assistant_models.enums import MediaType, StreamType from music_assistant_models.errors import ( LoginFailed, MediaNotFoundError, @@ -17,36 +17,18 @@ SetupFailedError, ) from music_assistant_models.media_items import ( - Album, - Artist, - Audiobook, AudioFormat, BrowseFolder, ItemMapping, - MediaItemChapter, - MediaItemImage, MediaItemType, - Podcast, - PodcastEpisode, - ProviderMapping, - UniqueList, ) from music_assistant_models.streamdetails import MultiPartPath, StreamDetails -from music_assistant.constants import ( - CONF_PASSWORD, - CONF_USERNAME, - DB_TABLE_PROVIDER_MAPPINGS, - VERBOSE_LOG_LEVEL, -) -from music_assistant.helpers.tags import AudioTags, async_parse_tags +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME, VERBOSE_LOG_LEVEL from music_assistant.providers.filesystem_local import LocalFileSystemProvider from music_assistant.providers.filesystem_local.constants import ( - AUDIOBOOK_EXTENSIONS, CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, - IMAGE_EXTENSIONS, PLAYLIST_EXTENSIONS, - PODCAST_EPISODE_EXTENSIONS, SUPPORTED_EXTENSIONS, TRACK_EXTENSIONS, ) @@ -57,6 +39,7 @@ CONF_URL, CONF_VERIFY_SSL, MAX_CONCURRENT_TASKS, + WEBDAV_TIMEOUT, ) from .helpers import build_webdav_url, webdav_propfind, webdav_test_connection @@ -83,9 +66,8 @@ def __init__( 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._session: aiohttp.ClientSession | None = None self.media_content_type = cast("str", config.get_value(CONF_CONTENT_TYPE)) - self.sync_running: bool = False - self.processed_audiobook_folders: set[str] = set() @property def instance_name_postfix(self) -> str | None: @@ -95,24 +77,36 @@ def instance_name_postfix(self) -> str | None: if parsed.path and parsed.path != "/": return PurePosixPath(parsed.path).name return parsed.netloc - except (ValueError, TypeError): - return "Invalid URL" + except Exception: + return "WebDAV" - @property - def _auth(self) -> aiohttp.BasicAuth | None: - """Get BasicAuth for WebDAV requests.""" + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create HTTP session with proper authentication.""" + if self._session and not self._session.closed: + return self._session + + auth = None if self.username: - return aiohttp.BasicAuth(self.username, self.password or "") - return None + auth = aiohttp.BasicAuth(self.username, self.password or "") + + connector = aiohttp.TCPConnector(ssl=self.verify_ssl) + + self._session = aiohttp.ClientSession( + auth=auth, + connector=connector, + timeout=aiohttp.ClientTimeout(total=WEBDAV_TIMEOUT), + ) + + return self._session async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" try: - session = self.mass.http_session if self.verify_ssl else self.mass.http_session_no_ssl await webdav_test_connection( - session, self.base_url, - auth=self._auth, + self.username, + self.password, + self.verify_ssl, timeout=10, ) except (LoginFailed, SetupFailedError): @@ -122,6 +116,16 @@ async def handle_async_init(self) -> None: self.write_access = False + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + if self._session and not self._session.closed: + await self._session.close() + await super().unload(is_removed) + + def get_absolute_path(self, file_path: str) -> str: + """Return authenticated WebDAV URL for given file path.""" + return self._build_authenticated_url(file_path) + async def exists(self, file_path: str) -> bool: """Return bool if this WebDAV resource exists.""" if not file_path: @@ -129,8 +133,8 @@ async def exists(self, file_path: str) -> bool: 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) + session = await self._get_session() + items = await webdav_propfind(session, webdav_url, depth=0) return len(items) > 0 or webdav_url.rstrip("/") == self.base_url.rstrip("/") except (LoginFailed, SetupFailedError): raise @@ -142,28 +146,30 @@ async def exists(self, file_path: str) -> bool: return False async def resolve(self, file_path: str) -> FileSystemItem: - """Resolve WebDAV path to FileSystemItem.""" + """Resolve WebDAV path to FileSystemItem with authenticated URL.""" 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 + session = await self._get_session() try: - items = await webdav_propfind(session, webdav_url, depth=0, auth=self._auth) + items = await webdav_propfind(session, webdav_url, depth=0) if not items: if webdav_url.rstrip("/") == self.base_url.rstrip("/"): return FileSystemItem( filename="", relative_path="", - absolute_path=self._build_authenticated_url(file_path), # With auth + absolute_path=self._build_authenticated_url(""), is_dir=True, ) raise MediaNotFoundError(f"WebDAV resource not found: {file_path}") webdav_item = items[0] + # Return FileSystemItem with authenticated URL - this is the key! + # The parent class will use this URL for async_parse_tags() return FileSystemItem( filename=PurePosixPath(file_path).name or webdav_item.name, relative_path=file_path, - absolute_path=self._build_authenticated_url(file_path), # With auth + 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, @@ -171,200 +177,26 @@ async def resolve(self, file_path: str) -> FileSystemItem: 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 - - def get_absolute_path(self, file_path: str) -> str: - """Return WebDAV URL for given file path.""" - return build_webdav_url(self.base_url, file_path) - - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse this provider's items.""" - item_path = path.split("://", 1)[1] if "://" in path else "" - - # For all paths (including subpaths for audiobooks/podcasts), browse actual folders - items: list[MediaItemType | ItemMapping | BrowseFolder] = [] - - try: - filesystem_items = await self._scandir(item_path) - - for item in filesystem_items: - if not item.is_dir and ("." not in item.filename or not item.ext): - continue - - if item.is_dir: - items.append( - BrowseFolder( - item_id=item.relative_path, - provider=self.instance_id, - path=f"{self.instance_id}://{item.relative_path}", - name=item.filename, - is_playable=True, - ) - ) - elif item.ext in TRACK_EXTENSIONS: - items.append( - ItemMapping( - media_type=MediaType.TRACK, - item_id=item.relative_path, - provider=self.instance_id, - name=item.filename, - ) - ) - elif item.ext in PLAYLIST_EXTENSIONS: - items.append( - ItemMapping( - media_type=MediaType.PLAYLIST, - item_id=item.relative_path, - provider=self.instance_id, - name=item.filename, - ) - ) - except Exception as err: - self.logger.error(f"Failed to browse WebDAV path {item_path}: {err}") - - return items - - async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: - """Get full audiobook details by id.""" - file_item = await self.resolve(prov_audiobook_id) - - if file_item.is_dir: - items = await self._scandir(prov_audiobook_id) - audiobook_files = [f for f in items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS] - if not audiobook_files: - raise MediaNotFoundError(f"No audiobook files found in folder: {prov_audiobook_id}") - - sorted_files = sorted(audiobook_files, key=lambda x: x.filename) - file_item = sorted_files[0] - - tags = await async_parse_tags( - file_item.absolute_path, file_item.file_size - ) # ← Use absolute_path - audiobook = self._build_audiobook_from_tags(file_item, tags) - - if file_item.relative_path != prov_audiobook_id: - audiobook.item_id = prov_audiobook_id - - return audiobook - - async def get_podcast_episodes( - self, - prov_podcast_id: str, - ) -> AsyncGenerator[PodcastEpisode, None]: - """Get all episodes for a podcast.""" - items = await self._scandir(prov_podcast_id) - episode_files = [f for f in items if not f.is_dir and f.ext in PODCAST_EPISODE_EXTENSIONS] - - for episode_file in sorted(episode_files, key=lambda x: x.filename): - tags = await async_parse_tags(episode_file.absolute_path, episode_file.file_size) - episode = self._build_podcast_episode_from_tags(episode_file, tags) - yield episode - - async def sync_library(self, media_type: MediaType, import_as_favorite: bool = False) -> None: - """Run library sync for WebDAV provider.""" - 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 - self.processed_audiobook_folders.clear() - - try: - assert self.mass.music.database - 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 _get_audiobook_chapters_from_cache( - self, item_id: str, file_item: FileSystemItem - ) -> list[tuple[str, float]] | None: - """Get audiobook chapters from cache, rebuilding if necessary.""" - file_based_chapters = await self.cache.get( - key=item_id, - provider=self.instance_id, - category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, - ) - - if file_based_chapters is None and file_item.is_dir: - self.logger.debug(f"Cache miss for audiobook chapters, re-parsing: {item_id}") - items = await self._scandir(item_id) - audiobook_files = [f for f in items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS] - - if audiobook_files: - await self._process_multifile_audiobook(item_id, audiobook_files, None) - file_based_chapters = cast( - "list[tuple[str, float]] | None", - await self.cache.get( - key=item_id, - provider=self.instance_id, - category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, - ), - ) - return cast("list[tuple[str, float]] | None", file_based_chapters) - - 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 + raise MediaNotFoundError(f"Failed to resolve WebDAV path {file_path}: {err}") from err async def _scandir(self, path: str) -> list[FileSystemItem]: """List WebDAV directory contents.""" 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 + session = await self._get_session() try: - webdav_items = await webdav_propfind(session, webdav_url, depth=1, auth=self._auth) + webdav_items = await webdav_propfind(session, webdav_url, depth=1) filesystem_items: list[FileSystemItem] = [] for webdav_item in webdav_items: - # SKIP RECYCLE BIN + # Skip recycle bin if "#recycle" in webdav_item.name.lower(): continue + decoded_href = unquote(webdav_item.href) decoded_base_url = unquote(self.base_url) - # Extract path from full URL for comparison parsed_webdav_url = urlparse(webdav_url) webdav_path = parsed_webdav_url.path.rstrip("/") @@ -410,6 +242,107 @@ async def _scandir(self, path: str) -> list[FileSystemItem]: ) return [] + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse this provider's items.""" + if self.media_content_type == "podcasts": + return await self.mass.music.podcasts.library_items(provider=self.instance_id) + if self.media_content_type == "audiobooks": + return await self.mass.music.audiobooks.library_items(provider=self.instance_id) + + items: list[MediaItemType | ItemMapping | BrowseFolder] = [] + item_path = path.split("://", 1)[1] if "://" in path else "" + + try: + filesystem_items = await self._scandir(item_path) + + for item in filesystem_items: + if not item.is_dir and ("." not in item.filename or not item.ext): + continue + + if item.is_dir: + items.append( + BrowseFolder( + item_id=item.relative_path, + provider=self.instance_id, + path=f"{self.instance_id}://{item.relative_path}", + name=item.filename, + is_playable=True, + ) + ) + elif item.ext in TRACK_EXTENSIONS: + items.append( + ItemMapping( + media_type=MediaType.TRACK, + item_id=item.relative_path, + provider=self.instance_id, + name=item.filename, + ) + ) + elif item.ext in PLAYLIST_EXTENSIONS: + items.append( + ItemMapping( + media_type=MediaType.PLAYLIST, + item_id=item.relative_path, + provider=self.instance_id, + name=item.filename, + ) + ) + except Exception as err: + self.logger.error(f"Failed to browse WebDAV path {item_path}: {err}") + + return items + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Return the content details for the given media when it will be streamed.""" + # For audiobooks, check if multi-file and use MultiPartPath + if media_type == MediaType.AUDIOBOOK: + file_item = await self.resolve(item_id) + + if file_item.is_dir: + # Check cache for multi-file chapters + file_based_chapters = await self.cache.get( + key=item_id, + provider=self.instance_id, + category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, + ) + + if file_based_chapters: + # Multi-file audiobook - use MultiPartPath list + audiobook = await self.mass.music.audiobooks.get_library_item_by_prov_id( + item_id, self.instance_id + ) + if audiobook is None: + raise MediaNotFoundError(f"Audiobook not found: {item_id}") + + prov_mapping = next( + (x for x in audiobook.provider_mappings if x.item_id == item_id), None + ) + audio_format = prov_mapping.audio_format if prov_mapping else AudioFormat() + + # Build MultiPartPath list for core's get_multi_file_stream + multi_parts = [ + MultiPartPath( + path=self._build_authenticated_url(chapter_path), + duration=duration, + ) + for chapter_path, duration in file_based_chapters + ] + + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=audio_format, + media_type=MediaType.AUDIOBOOK, + stream_type=StreamType.HTTP, # Not CUSTOM - let core handle it! + duration=audiobook.duration, + path=multi_parts, # List of MultiPartPath + can_seek=True, + allow_seek=True, + ) + + # All other cases (single files, tracks, podcasts) use parent implementation + return await super().get_stream_details(item_id, media_type) + async def _scan_recursive( self, path: str, @@ -425,8 +358,8 @@ async def _scan_recursive( dirs = [item for item in items if item.is_dir] files = [item for item in items if not item.is_dir] - # ADD SEMAPHORE FOR DIRECTORY SCANNING - dir_semaphore = asyncio.Semaphore(3) # Limit concurrent directory scans + # Limit concurrent directory scans + dir_semaphore = asyncio.Semaphore(3) async def scan_dir_limited(item: FileSystemItem) -> None: async with dir_semaphore: @@ -441,8 +374,16 @@ async def scan_dir_limited(item: FileSystemItem) -> None: async def process_with_semaphore(item: FileSystemItem) -> None: async with semaphore: + if item.ext not in SUPPORTED_EXTENSIONS: + return + prev_checksum = file_checksums.get(item.relative_path) - if await self._process_webdav_item(item, prev_checksum, import_as_favorite): + + # Call parent's synchronous _process_item in a thread + # item.absolute_path already has authenticated URL from resolve() + if await asyncio.to_thread( + self._process_item, item, prev_checksum, import_as_favorite + ): cur_filenames.add(item.relative_path) file_tasks = [process_with_semaphore(item) for item in files] @@ -465,524 +406,17 @@ async def process_with_semaphore(item: FileSystemItem) -> None: except Exception as err: self.logger.warning(f"Failed to scan WebDAV path {path}: {err}") - async def _process_webdav_item( - self, - item: FileSystemItem, - prev_checksum: str | None, - import_as_favorite: bool, - ) -> bool: - """Process a single WebDAV item during library sync.""" - if item.is_dir: - return False - - if not item.ext or item.ext not in SUPPORTED_EXTENSIONS: - return False - - try: - self.logger.log(VERBOSE_LOG_LEVEL, f"Processing WebDAV item: {item.relative_path}") - - # Skip if unchanged - if item.checksum == prev_checksum: - return True - - # Process based on media type - if item.ext in TRACK_EXTENSIONS and self.media_content_type == "music": - await self._process_track(item, prev_checksum) - return True - - if item.ext in AUDIOBOOK_EXTENSIONS and self.media_content_type == "audiobooks": - # Check if this is a multi-file audiobook folder - folder_path = str(PurePosixPath(item.relative_path).parent) - - # Skip if we've already processed this folder - if folder_path in self.processed_audiobook_folders: - return True - - # Scan folder to see if there are multiple audiobook files - folder_items = await self._scandir(folder_path) - audiobook_files = [ - f for f in folder_items if not f.is_dir and f.ext in AUDIOBOOK_EXTENSIONS - ] - - if len(audiobook_files) > 1: - # Multi-file audiobook - process entire folder - await self._process_multifile_audiobook( - folder_path, audiobook_files, prev_checksum - ) - self.processed_audiobook_folders.add(folder_path) - else: - # Single file audiobook - await self._process_audiobook(item, prev_checksum) - return True - - if item.ext in PODCAST_EPISODE_EXTENSIONS and self.media_content_type == "podcasts": - await self._process_podcast(item, prev_checksum) - return True - - except Exception as err: - self.logger.error( - f"Error processing WebDAV item {item.relative_path}: {err}", - exc_info=self.logger.isEnabledFor(10), - ) - return False - - async def _process_track(self, item: FileSystemItem, prev_checksum: str | None) -> None: - """Process a track item.""" - try: - tags = await async_parse_tags(item.absolute_path, item.file_size) - track = await self._parse_track(item, tags) - - # Add folder images to album if present - if track.album and isinstance(track.album, Album): - album_path = str(PurePosixPath(item.relative_path).parent) - folder_images = await self._get_local_images(album_path) - if folder_images and not track.album.metadata.images: - track.album.metadata.images = folder_images - - # Add folder images to album artists - for artist in track.album.artists: - if isinstance(artist, Artist) and not artist.metadata.images: - artist_mapping = next( - ( - m - for m in artist.provider_mappings - if m.provider_instance == self.instance_id - ), - None, - ) - if artist_mapping and artist_mapping.url: - artist_images = await self._get_local_images( - artist_mapping.url, extra_thumb_names=("artist",) - ) - if artist_images: - artist.metadata.images = artist_images - - # Also add images to track artists - for artist in track.artists: - if isinstance(artist, Artist) and not artist.metadata.images: - artist_mapping = next( - ( - m - for m in artist.provider_mappings - if m.provider_instance == self.instance_id - ), - None, - ) - if artist_mapping and artist_mapping.url: - artist_images = await self._get_local_images( - artist_mapping.url, extra_thumb_names=("artist",) - ) - if artist_images: - artist.metadata.images = artist_images - - await self.mass.music.tracks.add_item_to_library( - track, overwrite_existing=prev_checksum is not None - ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError): - raise - except aiohttp.ClientError as err: - self.logger.error( - f"Connection error processing WebDAV track {item.relative_path}: {err}" - ) - except Exception as err: - self.logger.error(f"Failed to process WebDAV track {item.relative_path}: {err}") - - async def _process_audiobook(self, item: FileSystemItem, prev_checksum: str | None) -> None: - """Process an audiobook item.""" - try: - tags = await async_parse_tags(item.absolute_path, item.file_size) - audiobook = self._build_audiobook_from_tags(item, tags) # Use custom builder - await self.mass.music.audiobooks.add_item_to_library( - audiobook, overwrite_existing=prev_checksum is not None - ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError): - raise - except aiohttp.ClientError as err: - self.logger.error( - f"Connection error processing WebDAV audiobook {item.relative_path}: {err}" - ) - except Exception as err: - self.logger.error(f"Failed to process WebDAV audiobook {item.relative_path}: {err}") - - async def _process_multifile_audiobook( - self, - folder_path: str, - audiobook_files: list[FileSystemItem], - prev_checksum: str | None, - ) -> None: - """Process a multi-file audiobook folder.""" - try: - sorted_files = sorted(audiobook_files, key=lambda x: x.filename) - first_file = sorted_files[0] - - tags = await async_parse_tags(first_file.absolute_path, first_file.file_size) - - # Use custom builder instead of parent's _parse_audiobook - audiobook = self._build_audiobook_from_tags(first_file, tags) - - # Override item_id to be the folder - audiobook.item_id = folder_path - audiobook.provider_mappings = { - ProviderMapping( - item_id=folder_path, - provider_domain=self.domain, - provider_instance=self.instance_id, - ) - } - - # Build chapters from all files - chapters: list[MediaItemChapter] = [] - chapter_files: list[tuple[str, float]] = [] - total_duration = 0.0 - - for idx, file_item in enumerate(sorted_files, start=1): - file_tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) - duration = file_tags.duration or 0 - - chapters.append( - MediaItemChapter( - position=idx, - name=file_tags.title or file_item.filename, - start=total_duration, - end=total_duration + duration, - ) - ) - chapter_files.append((file_item.absolute_path, duration)) - total_duration += duration - - audiobook.duration = int(total_duration) - audiobook.metadata.chapters = chapters - - # Cache chapter files for streaming lookup - await self.cache.set( - key=folder_path, - data=chapter_files, - provider=self.instance_id, - category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, - ) - - # Add folder images - folder_images = await self._get_local_images(folder_path) - if folder_images: - audiobook.metadata.images = folder_images - - await self.mass.music.audiobooks.add_item_to_library( - audiobook, overwrite_existing=prev_checksum is not None - ) - - except (LoginFailed, SetupFailedError, ProviderUnavailableError): - raise - except aiohttp.ClientError as err: - self.logger.error( - f"Connection error processing multi-file audiobook {folder_path}: {err}" - ) - except Exception as err: - self.logger.error(f"Failed to process multi-file audiobook {folder_path}: {err}") - - async def _process_podcast(self, item: FileSystemItem, prev_checksum: str | None) -> None: - """Process a podcast episode item.""" - try: - tags = await async_parse_tags(item.absolute_path, item.file_size) - episode = self._build_podcast_episode_from_tags(item, tags) # Use custom builder - - podcast_folder = str(PurePosixPath(item.relative_path).parent) - folder_images = await self._get_local_images(podcast_folder) - - assert isinstance(episode.podcast, Podcast) - if folder_images: - episode.podcast.metadata.images = folder_images - - await self.mass.music.podcasts.add_item_to_library( - episode.podcast, overwrite_existing=prev_checksum is not None - ) - except (LoginFailed, SetupFailedError, ProviderUnavailableError): - raise - except aiohttp.ClientError as err: - self.logger.error( - f"Connection error processing WebDAV podcast {item.relative_path}: {err}" - ) - except Exception as err: - self.logger.error(f"Failed to process WebDAV podcast {item.relative_path}: {err}") - - async def _get_local_images( - self, - item_path: str, - extra_thumb_names: tuple[str, ...] | None = None, - ) -> UniqueList[MediaItemImage]: - """Get images from WebDAV folder.""" - try: - # Scan the WebDAV directory for image files - items = await self._scandir(item_path) - images: list[MediaItemImage] = [] - - # Standard image filenames to look for - standard_names = {"folder", "cover", "albumart", "album", "front"} - if extra_thumb_names: - standard_names.update(extra_thumb_names) - - for item in items: - if item.is_dir or not item.ext: - continue - - if item.ext in IMAGE_EXTENSIONS: - # Check if it's a standard cover image name - name_lower = item.filename.lower().rsplit(".", 1)[0] - if name_lower in standard_names: - webdav_url = build_webdav_url(self.base_url, item.relative_path) - images.append( - MediaItemImage( - type=ImageType.THUMB, - path=webdav_url, - provider=self.instance_id, - remotely_accessible=False, - ) - ) - - return UniqueList(images) - except Exception as err: - self.logger.debug(f"Failed to get images from {item_path}: {err}") - return UniqueList() - - 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 _get_stream_details_for_track(self, item_id: str) -> StreamDetails: - """Return the streamdetails for a track/song.""" - library_item = await self.mass.music.tracks.get_library_item_by_prov_id( - item_id, self.instance_id - ) - if library_item is None: - file_item = await self.resolve(item_id) - tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) - library_item = await self._parse_track(file_item, tags) - - prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) - file_item = await self.resolve(item_id) - - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=prov_mapping.audio_format, - media_type=MediaType.TRACK, - stream_type=StreamType.HTTP, - duration=library_item.duration, - size=file_item.file_size, - path=file_item.absolute_path, # NEW: use absolute_path instead of auth_url - can_seek=True, - allow_seek=True, - ) - - async def _get_stream_details_for_podcast_episode(self, item_id: str) -> StreamDetails: - """Return the streamdetails for a podcast episode.""" - file_item = await self.resolve(item_id) - tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) - - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=AudioFormat( - content_type=ContentType.try_parse(file_item.ext or tags.format), - sample_rate=tags.sample_rate, - bit_depth=tags.bits_per_sample, - channels=tags.channels, - bit_rate=tags.bit_rate, - ), - media_type=MediaType.PODCAST_EPISODE, - stream_type=StreamType.HTTP, - duration=int(tags.duration or 0), - size=file_item.file_size, - path=file_item.absolute_path, - allow_seek=True, - can_seek=True, - ) - - async def _get_stream_details_for_audiobook(self, item_id: str) -> StreamDetails: - """Return the streamdetails for an audiobook.""" - library_item = await self.mass.music.audiobooks.get_library_item_by_prov_id( - item_id, self.instance_id - ) - if library_item is None: - file_item = await self.resolve(item_id) - tags = await async_parse_tags(file_item.absolute_path, file_item.file_size) - library_item = self._build_audiobook_from_tags(file_item, tags) - - prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) - file_item = await self.resolve(item_id) - - # Check for multi-file audiobook chapters - file_based_chapters = await self._get_audiobook_chapters_from_cache(item_id, file_item) - - if file_based_chapters: - # Multi-file audiobook - chapters already have authenticated URLs - chapter_paths = [ - MultiPartPath(path=ch[0], duration=ch[1]) for ch in file_based_chapters - ] - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=prov_mapping.audio_format, - media_type=MediaType.AUDIOBOOK, - stream_type=StreamType.HTTP, - duration=library_item.duration, - path=chapter_paths, - allow_seek=True, - ) - - # Single file audiobook - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=prov_mapping.audio_format, - media_type=MediaType.AUDIOBOOK, - stream_type=StreamType.HTTP, - duration=library_item.duration, - size=file_item.file_size, - path=file_item.absolute_path, - can_seek=True, - allow_seek=True, - ) - - def _build_audiobook_from_tags(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook: - """Build audiobook from tags without filesystem operations.""" - book_name = tags.album or tags.title - - audiobook = Audiobook( - item_id=file_item.relative_path, - provider=self.instance_id, - name=book_name, - sort_name=tags.album_sort or tags.title_sort, - version=tags.version, - duration=int(tags.duration or 0), - provider_mappings={ - ProviderMapping( - item_id=file_item.relative_path, - provider_domain=self.domain, - provider_instance=self.instance_id, - audio_format=AudioFormat( - content_type=ContentType.try_parse(file_item.ext or tags.format), - sample_rate=tags.sample_rate, - bit_depth=tags.bits_per_sample, - channels=tags.channels, - bit_rate=tags.bit_rate, - ), - details=file_item.checksum, - ) - }, - ) - - # Set authors and metadata - audiobook.authors.set(tags.writers or tags.album_artists or tags.artists) - audiobook.metadata.genres = set(tags.genres) - audiobook.metadata.copyright = tags.get("copyright") - audiobook.metadata.description = tags.get("comment") - - if tags.musicbrainz_recordingid: - audiobook.mbid = tags.musicbrainz_recordingid - - # Handle embedded chapters - if tags.chapters: - audiobook.metadata.chapters = [ - MediaItemChapter( - position=chapter.chapter_id, - name=chapter.title or f"Chapter {chapter.chapter_id}", - start=chapter.position_start, - end=chapter.position_end, - ) - for chapter in tags.chapters - ] - - # Handle embedded cover image - if tags.has_cover_image: - audiobook.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=file_item.relative_path, - provider=self.instance_id, - remotely_accessible=False, - ) - ) - - return audiobook - - def _build_podcast_episode_from_tags( - self, file_item: FileSystemItem, tags: AudioTags - ) -> PodcastEpisode: - """Build podcast episode from tags without filesystem operations.""" - podcast_name = tags.album or PurePosixPath(file_item.relative_path).parent.name - podcast_path = str(PurePosixPath(file_item.relative_path).parent) - - episode = PodcastEpisode( - item_id=file_item.relative_path, - provider=self.instance_id, - name=tags.title, - sort_name=tags.title_sort, - provider_mappings={ - ProviderMapping( - item_id=file_item.relative_path, - provider_domain=self.domain, - provider_instance=self.instance_id, - audio_format=AudioFormat( - content_type=ContentType.try_parse(file_item.ext or tags.format), - sample_rate=tags.sample_rate, - bit_depth=tags.bits_per_sample, - channels=tags.channels, - bit_rate=tags.bit_rate, - ), - details=file_item.checksum, - ) - }, - position=tags.track or 0, - duration=int(tags.duration or 0), - podcast=Podcast( - item_id=podcast_path, - provider=self.instance_id, - name=podcast_name, - sort_name=tags.album_sort, - publisher=tags.get("publisher"), - total_episodes=0, - provider_mappings={ - ProviderMapping( - item_id=podcast_path, - provider_domain=self.domain, - provider_instance=self.instance_id, - ) - }, - ), - ) + 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) - # Set metadata - episode.metadata.genres = set(tags.genres) - episode.metadata.copyright = tags.get("copyright") - episode.metadata.description = tags.get("comment") - - # Handle chapters - if tags.chapters: - episode.metadata.chapters = [ - MediaItemChapter( - position=chapter.chapter_id, - name=chapter.title or f"Chapter {chapter.chapter_id}", - start=chapter.position_start, - end=chapter.position_end, - ) - for chapter in tags.chapters - ] - - # Handle embedded cover - if tags.has_cover_image: - episode.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=file_item.relative_path, - provider=self.instance_id, - remotely_accessible=False, - ) + 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 episode + return webdav_url From b4952ee5ca3028e86ead565ecc2c129cf0245312 Mon Sep 17 00:00:00 2001 From: Gav Date: Tue, 7 Oct 2025 02:19:55 +1000 Subject: [PATCH 07/10] refactoring --- music_assistant/providers/webdav/provider.py | 52 +++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index ba6cc0c266..9cb3a3060b 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from collections.abc import Sequence from pathlib import PurePosixPath from typing import TYPE_CHECKING, cast @@ -24,7 +25,12 @@ ) from music_assistant_models.streamdetails import MultiPartPath, StreamDetails -from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME, VERBOSE_LOG_LEVEL +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.constants import ( CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, @@ -420,3 +426,47 @@ def _build_authenticated_url(self, file_path: str) -> str: ) return webdav_url + + 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 + start_time = time.time() + + if getattr(self, "sync_running", False): + 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} " + f"WHERE provider_instance = '{self.instance_id}' " + f"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()) + + self.sync_running = True + try: + # Use async WebDAV scanning instead of os.scandir + await self._scan_recursive("", cur_filenames, file_checksums, import_as_favorite) + finally: + self.sync_running = False + + end_time = time.time() + self.logger.info( + "Library sync for %s completed in %.2f seconds", + self.name, + end_time - start_time, + ) + + # Work out deletions - use parent's method + deleted_files = prev_filenames - cur_filenames + await self._process_deletions(deleted_files) + + # Process orphaned albums and artists - use parent's method + await self._process_orphaned_albums_and_artists() From d382776517256274ce589b8d4b78ccf49020dadf Mon Sep 17 00:00:00 2001 From: Gav Date: Tue, 7 Oct 2025 15:14:59 +1000 Subject: [PATCH 08/10] major refactor --- .../providers/filesystem_local/__init__.py | 87 ++-- music_assistant/providers/webdav/provider.py | 421 ++++++------------ 2 files changed, 194 insertions(+), 314 deletions(-) diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index 557ce34835..7c84a200a3 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -7,6 +7,7 @@ import logging import os import os.path +import stat import time import urllib.parse from collections.abc import AsyncGenerator, Iterator, Sequence @@ -281,8 +282,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 +757,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 +1181,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 +1255,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 +1515,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 +1572,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 +1725,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 +1789,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/provider.py b/music_assistant/providers/webdav/provider.py index 9cb3a3060b..aecc4a7c5d 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -3,27 +3,17 @@ from __future__ import annotations import asyncio -import time -from collections.abc import Sequence from pathlib import PurePosixPath from typing import TYPE_CHECKING, cast from urllib.parse import quote, unquote, urlparse, urlunparse import aiohttp -from music_assistant_models.enums import MediaType, StreamType from music_assistant_models.errors import ( LoginFailed, MediaNotFoundError, ProviderUnavailableError, SetupFailedError, ) -from music_assistant_models.media_items import ( - AudioFormat, - BrowseFolder, - ItemMapping, - MediaItemType, -) -from music_assistant_models.streamdetails import MultiPartPath, StreamDetails from music_assistant.constants import ( CONF_PASSWORD, @@ -32,25 +22,14 @@ VERBOSE_LOG_LEVEL, ) from music_assistant.providers.filesystem_local import LocalFileSystemProvider -from music_assistant.providers.filesystem_local.constants import ( - CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, - PLAYLIST_EXTENSIONS, - SUPPORTED_EXTENSIONS, - TRACK_EXTENSIONS, -) from music_assistant.providers.filesystem_local.helpers import FileSystemItem -from .constants import ( - CONF_CONTENT_TYPE, - CONF_URL, - CONF_VERIFY_SSL, - MAX_CONCURRENT_TASKS, - WEBDAV_TIMEOUT, -) +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 @@ -72,7 +51,6 @@ def __init__( 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._session: aiohttp.ClientSession | None = None self.media_content_type = cast("str", config.get_value(CONF_CONTENT_TYPE)) @property @@ -83,27 +61,15 @@ def instance_name_postfix(self) -> str | None: if parsed.path and parsed.path != "/": return PurePosixPath(parsed.path).name return parsed.netloc - except Exception: - return "WebDAV" - - async def _get_session(self) -> aiohttp.ClientSession: - """Get or create HTTP session with proper authentication.""" - if self._session and not self._session.closed: - return self._session + except (ValueError, TypeError): + return "Invalid URL" - auth = None + @property + def _auth(self) -> aiohttp.BasicAuth | None: + """Get BasicAuth for WebDAV requests.""" if self.username: - auth = aiohttp.BasicAuth(self.username, self.password or "") - - connector = aiohttp.TCPConnector(ssl=self.verify_ssl) - - self._session = aiohttp.ClientSession( - auth=auth, - connector=connector, - timeout=aiohttp.ClientTimeout(total=WEBDAV_TIMEOUT), - ) - - return self._session + return aiohttp.BasicAuth(self.username, self.password or "") + return None async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" @@ -122,25 +88,34 @@ async def handle_async_init(self) -> None: self.write_access = False - async def unload(self, is_removed: bool = False) -> None: - """Handle unload/close of the provider.""" - if self._session and not self._session.closed: - await self._session.close() - await super().unload(is_removed) + 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) + ) - def get_absolute_path(self, file_path: str) -> str: - """Return authenticated WebDAV URL for given file path.""" - return self._build_authenticated_url(file_path) + return webdav_url - async def exists(self, file_path: str) -> bool: - """Return bool if this WebDAV resource exists.""" + 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 = await self._get_session() - items = await webdav_propfind(session, webdav_url, depth=0) + 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 @@ -151,27 +126,25 @@ async def exists(self, file_path: str) -> bool: self.logger.debug(f"WebDAV exists check failed for {file_path}: {err}") return False - async def resolve(self, file_path: str) -> FileSystemItem: - """Resolve WebDAV path to FileSystemItem with authenticated URL.""" + 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 = await self._get_session() + 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) + 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(""), + 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 with authenticated URL - this is the key! - # The parent class will use this URL for async_parse_tags() return FileSystemItem( filename=PurePosixPath(file_path).name or webdav_item.name, relative_path=file_path, @@ -183,40 +156,73 @@ async def resolve(self, file_path: str) -> FileSystemItem: except MediaNotFoundError: raise - except Exception as err: + 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(self, path: str) -> list[FileSystemItem]: + 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 = await self._get_session() + 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) + 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: - # Skip recycle bin + self.logger.debug( + f"Processing item: name={webdav_item.name}, 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) - decoded_base_url = unquote(self.base_url) - parsed_webdav_url = urlparse(webdav_url) - webdav_path = parsed_webdav_url.path.rstrip("/") + # 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 - if decoded_href.rstrip("/") == webdav_path: + 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}") - if decoded_href.startswith(decoded_base_url): - relative_path = decoded_href[len(decoded_base_url) :].strip("/") + # Calculate relative path by stripping base path + if href_path.startswith(base_path + "/") or href_path.startswith(base_path): + relative_path = href_path[len(base_path) :].strip("/") else: - decoded_name = unquote(webdav_item.name) + # 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]}, relative_path: {relative_path}" + ) + self.logger.debug( + f"Calculated relative_path: '{relative_path}' for {webdav_item.name}" + ) decoded_name = unquote(webdav_item.name) @@ -230,6 +236,10 @@ async def _scandir(self, path: str) -> list[FileSystemItem]: 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 @@ -242,112 +252,62 @@ async def _scandir(self, path: str) -> list[FileSystemItem]: ) raise ProviderUnavailableError(f"WebDAV server connection failed: {err}") from err except Exception as err: - self.logger.log( - VERBOSE_LOG_LEVEL, - f"Failed to list WebDAV directory {path}: {err}", - ) + self.logger.error(f"Failed to list WebDAV directory {path}: {err}", exc_info=True) + return [] - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse this provider's items.""" - if self.media_content_type == "podcasts": - return await self.mass.music.podcasts.library_items(provider=self.instance_id) - if self.media_content_type == "audiobooks": - return await self.mass.music.audiobooks.library_items(provider=self.instance_id) + 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 - items: list[MediaItemType | ItemMapping | BrowseFolder] = [] - item_path = path.split("://", 1)[1] if "://" in path else "" + 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() - try: - filesystem_items = await self._scandir(item_path) + 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 - for item in filesystem_items: - if not item.is_dir and ("." not in item.filename or not item.ext): - continue + if self.sync_running: + self.logger.warning(f"Library sync already running for {self.name}") + return - if item.is_dir: - items.append( - BrowseFolder( - item_id=item.relative_path, - provider=self.instance_id, - path=f"{self.instance_id}://{item.relative_path}", - name=item.filename, - is_playable=True, - ) - ) - elif item.ext in TRACK_EXTENSIONS: - items.append( - ItemMapping( - media_type=MediaType.TRACK, - item_id=item.relative_path, - provider=self.instance_id, - name=item.filename, - ) - ) - elif item.ext in PLAYLIST_EXTENSIONS: - items.append( - ItemMapping( - media_type=MediaType.PLAYLIST, - item_id=item.relative_path, - provider=self.instance_id, - name=item.filename, - ) - ) - except Exception as err: - self.logger.error(f"Failed to browse WebDAV path {item_path}: {err}") - - return items - - async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Return the content details for the given media when it will be streamed.""" - # For audiobooks, check if multi-file and use MultiPartPath - if media_type == MediaType.AUDIOBOOK: - file_item = await self.resolve(item_id) - - if file_item.is_dir: - # Check cache for multi-file chapters - file_based_chapters = await self.cache.get( - key=item_id, - provider=self.instance_id, - category=CACHE_CATEGORY_AUDIOBOOK_CHAPTERS, - ) + self.logger.info(f"Started library sync for WebDAV provider {self.name}") + self.sync_running = True - if file_based_chapters: - # Multi-file audiobook - use MultiPartPath list - audiobook = await self.mass.music.audiobooks.get_library_item_by_prov_id( - item_id, self.instance_id - ) - if audiobook is None: - raise MediaNotFoundError(f"Audiobook not found: {item_id}") + 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"]) - prov_mapping = next( - (x for x in audiobook.provider_mappings if x.item_id == item_id), None - ) - audio_format = prov_mapping.audio_format if prov_mapping else AudioFormat() - - # Build MultiPartPath list for core's get_multi_file_stream - multi_parts = [ - MultiPartPath( - path=self._build_authenticated_url(chapter_path), - duration=duration, - ) - for chapter_path, duration in file_based_chapters - ] - - return StreamDetails( - provider=self.instance_id, - item_id=item_id, - audio_format=audio_format, - media_type=MediaType.AUDIOBOOK, - stream_type=StreamType.HTTP, # Not CUSTOM - let core handle it! - duration=audiobook.duration, - path=multi_parts, # List of MultiPartPath - can_seek=True, - allow_seek=True, - ) + cur_filenames: set[str] = set() + prev_filenames: set[str] = set(file_checksums.keys()) + + await self._scan_recursive("", cur_filenames, file_checksums, import_as_favorite) - # All other cases (single files, tracks, podcasts) use parent implementation - return await super().get_stream_details(item_id, media_type) + 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, @@ -356,54 +316,28 @@ async def _scan_recursive( file_checksums: dict[str, str], import_as_favorite: bool, ) -> None: - """Recursively scan WebDAV directory with concurrent processing.""" + """Recursively scan WebDAV directory.""" try: - items = await self._scandir(path) + 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] - # Limit concurrent directory scans - dir_semaphore = asyncio.Semaphore(3) - - async def scan_dir_limited(item: FileSystemItem) -> None: - async with dir_semaphore: - await self._scan_recursive( - item.relative_path, cur_filenames, file_checksums, import_as_favorite - ) - - dir_tasks = [scan_dir_limited(item) for item in dirs] - - # Process files concurrently with semaphore - semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS) - - async def process_with_semaphore(item: FileSystemItem) -> None: - async with semaphore: - if item.ext not in SUPPORTED_EXTENSIONS: - return - - prev_checksum = file_checksums.get(item.relative_path) - - # Call parent's synchronous _process_item in a thread - # item.absolute_path already has authenticated URL from resolve() - if await asyncio.to_thread( - self._process_item, item, prev_checksum, import_as_favorite - ): - cur_filenames.add(item.relative_path) - - file_tasks = [process_with_semaphore(item) for item in files] - - # Run all tasks concurrently - all_tasks = dir_tasks + file_tasks - results = await asyncio.gather(*all_tasks, return_exceptions=True) - - # Log any errors - for result in results: - if isinstance(result, Exception) and not isinstance( - result, (LoginFailed, SetupFailedError, ProviderUnavailableError) + # 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 ): - self.logger.warning(f"Error during scan: {result}") + 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 @@ -411,62 +345,3 @@ async def process_with_semaphore(item: FileSystemItem) -> None: 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}") - - 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 sync_library(self, media_type: MediaType, import_as_favorite: bool = False) -> None: - """Run library sync for WebDAV provider.""" - assert self.mass.music.database - start_time = time.time() - - if getattr(self, "sync_running", False): - 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} " - f"WHERE provider_instance = '{self.instance_id}' " - f"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()) - - self.sync_running = True - try: - # Use async WebDAV scanning instead of os.scandir - await self._scan_recursive("", cur_filenames, file_checksums, import_as_favorite) - finally: - self.sync_running = False - - end_time = time.time() - self.logger.info( - "Library sync for %s completed in %.2f seconds", - self.name, - end_time - start_time, - ) - - # Work out deletions - use parent's method - deleted_files = prev_filenames - cur_filenames - await self._process_deletions(deleted_files) - - # Process orphaned albums and artists - use parent's method - await self._process_orphaned_albums_and_artists() From 6dbc10bcf6e546c32870b6eb1b9407581b5c9e77 Mon Sep 17 00:00:00 2001 From: Gav Date: Tue, 7 Oct 2025 20:36:57 +1000 Subject: [PATCH 09/10] Remove unused import --- music_assistant/providers/filesystem_local/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index 7c84a200a3..c4252cefb8 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -7,7 +7,6 @@ import logging import os import os.path -import stat import time import urllib.parse from collections.abc import AsyncGenerator, Iterator, Sequence From 96e2abde0bb33a893b2997241a4690e54c399c59 Mon Sep 17 00:00:00 2001 From: Gav Date: Sun, 12 Oct 2025 00:34:42 +1000 Subject: [PATCH 10/10] Lint --- music_assistant/providers/webdav/helpers.py | 2 +- music_assistant/providers/webdav/provider.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/music_assistant/providers/webdav/helpers.py b/music_assistant/providers/webdav/helpers.py index 74f1fd10ba..22b260bc0d 100644 --- a/music_assistant/providers/webdav/helpers.py +++ b/music_assistant/providers/webdav/helpers.py @@ -182,7 +182,7 @@ async def webdav_test_connection( timeout: int = 10, ) -> None: """Test WebDAV connection and authentication.""" - auth = aiohttp.BasicAuth(username, password) if username else None + auth = aiohttp.BasicAuth(username, password) if username and password else None connector = aiohttp.TCPConnector(ssl=verify_ssl) async with aiohttp.ClientSession( diff --git a/music_assistant/providers/webdav/provider.py b/music_assistant/providers/webdav/provider.py index aecc4a7c5d..772abc299f 100644 --- a/music_assistant/providers/webdav/provider.py +++ b/music_assistant/providers/webdav/provider.py @@ -186,7 +186,8 @@ async def _scandir_impl(self, path: str) -> list[FileSystemItem]: for webdav_item in webdav_items: self.logger.debug( - f"Processing item: name={webdav_item.name}, href={webdav_item.href[:100]}, is_dir={webdav_item.is_dir}" + 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(): @@ -210,7 +211,7 @@ async def _scandir_impl(self, path: str) -> list[FileSystemItem]: self.logger.debug(f"After skip check, processing: {webdav_item.name}") # Calculate relative path by stripping base path - if href_path.startswith(base_path + "/") or href_path.startswith(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 @@ -218,7 +219,8 @@ async def _scandir_impl(self, path: str) -> list[FileSystemItem]: str(PurePosixPath(path) / decoded_name) if path else decoded_name ) self.logger.debug( - f"Item: {decoded_name}, href: {decoded_href[:80]}, relative_path: {relative_path}" + 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}" @@ -252,7 +254,7 @@ async def _scandir_impl(self, path: str) -> list[FileSystemItem]: ) raise ProviderUnavailableError(f"WebDAV server connection failed: {err}") from err except Exception as err: - self.logger.error(f"Failed to list WebDAV directory {path}: {err}", exc_info=True) + self.logger.exception(f"Failed to list WebDAV directory {path}: {err}") return []