diff --git a/music_assistant/providers/yutorah/__init__.py b/music_assistant/providers/yutorah/__init__.py new file mode 100644 index 0000000000..b9df101427 --- /dev/null +++ b/music_assistant/providers/yutorah/__init__.py @@ -0,0 +1,103 @@ +"""YuTorah music provider for Music Assistant. + +Streams Torah shiurim (audio lectures) from YUTorah Online (yutorah.org) +using their native mobile app JSON API (discovered via APK reverse engineering). + +Data model mapping: + Series (e.g. "Daf Yomi", "Ten Minute Halacha") → Podcast + Shiur within that series → PodcastEpisode (direct MP3) + +API base: https://yutorah.org/api/ +Login is required and unlocks the full paginated episode lists and full search. + +Authenticated endpoints (require userToken query param, obtained via login): + POST login/default {email, password} → {"loginSuccess": true, "userToken": "..."} + GET search/get?searchTerm=&seriesID=X&getFacets=true&userToken=T → page 1 (30 results) + GET search/get?searchTerm=&seriesID=X&getFacets=false&start=N&userToken=T → page N (N≥2) + GET search/get?searchTerm=QUERY&getFacets=true&userToken=T → full-text search + + IMPORTANT: searchTerm must always be present (even as empty string). Omitting it or + sending only seriesID/teacherID/subcategoryID without searchTerm returns "". The start + parameter is a 1-based PAGE NUMBER (not an offset), and must be omitted on the first + request. Page size is 30 results per page. + + search/get response shape (Solr-style): + { + "response": {"docs": [...shiur objects...], "numFound": N}, + "facet_counts": {"facet_fields": {"teachers": [...], "series": [...]}} + } + +Browse path structure: + yutorah:// → root folders + yutorah://series → all series (as folders) + yutorah://series/ → teacher sub-folders within a series + yutorah://series// → episodes for a teacher within a series + yutorah://teachers → all teachers + yutorah://teachers/ → all episodes by a teacher + yutorah://categories → topic category tree + yutorah://categories/ → series in a category + yutorah://recent → 50 most recent shiurim +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME + +from .constants import SUPPORTED_FEATURES +from .provider import YuTorahProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ( + ConfigValueType, + ProviderConfig, + ) + from music_assistant_models.provider import ProviderManifest + + from music_assistant import MusicAssistant + +__all__ = ["SUPPORTED_FEATURES", "YuTorahProvider", "get_config_entries", "setup"] + + +async def get_config_entries( + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 +) -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider. + + :param instance_id: id of an existing provider instance (None if new instance setup). + :param action: [optional] action key called from config entries UI. + :param values: the (intermediate) raw values for config entries sent with the action. + """ + return ( + ConfigEntry( + key="label_auth", + type=ConfigEntryType.LABEL, + label="A free YuTorah account is required. Sign up at yutorah.org.", + ), + ConfigEntry( + key=CONF_USERNAME, + type=ConfigEntryType.STRING, + label="Email address", + required=True, + ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=True, + ), + ) + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> YuTorahProvider: + """Initialize provider instance with the given configuration.""" + return YuTorahProvider(mass, manifest, config, SUPPORTED_FEATURES) diff --git a/music_assistant/providers/yutorah/browse.py b/music_assistant/providers/yutorah/browse.py new file mode 100644 index 0000000000..f730ccbc2d --- /dev/null +++ b/music_assistant/providers/yutorah/browse.py @@ -0,0 +1,313 @@ +"""Browse implementation for the YuTorah Music Assistant provider.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from music_assistant_models.media_items import ( + BrowseFolder, + ItemMapping, + MediaItemMetadata, + MediaItemType, + Podcast, + PodcastEpisode, + ProviderMapping, +) +from music_assistant_models.unique_list import UniqueList + +from .helpers import ( + _build_st_podcast, + _make_images, + _path_segment, + _segment_id, + _series_or_stub_podcast, + _series_to_podcast, + _shiur_to_episode, +) + + +class YuTorahBrowseMixin: + """Mixin providing browse() and private _browse_* helpers for YuTorahProvider. + + Requires the host class to provide: domain, instance_id, _api_get, + _fetch_series_list, _fetch_teachers_map, and _fetch_episodes_paged. + """ + + # ------------------------------------------------------------------ + # Interface required from the host class (YuTorahProvider). + # domain and instance_id are declared under TYPE_CHECKING so mypy can + # see them without overriding the real attributes on MusicProvider. + # The async methods below are stubs that YuTorahProvider overrides. + # ------------------------------------------------------------------ + + if TYPE_CHECKING: + domain: str + instance_id: str + + async def _api_get(self, endpoint: str, **params: Any) -> Any: + """Make a GET request to the YuTorah JSON API.""" + raise NotImplementedError + + async def _fetch_series_list(self) -> list[dict[str, Any]]: + """Fetch the full list of curated series.""" + raise NotImplementedError + + async def _fetch_teachers_map(self) -> dict[str, dict[str, Any]]: + """Fetch all teachers as a dict keyed by teacher ID string.""" + raise NotImplementedError + + async def _fetch_episodes_paged( + self, + parent_series_id: str | None = None, + **filter_params: str, + ) -> list[PodcastEpisode]: + """Fetch episodes from search/get with automatic pagination.""" + raise NotImplementedError + + # ------------------------------------------------------------------ + # Browse + # ------------------------------------------------------------------ + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse the YuTorah content tree. + + :param path: The path to browse (e.g. yutorah://series). + """ + section, p1, p2, *_ = [*path.split("://", 1)[1].split("/"), "", ""] + + if not section: + return self._browse_root() + if section == "series" and not p1: + return await self._browse_all_series() + if section == "series" and p1 and p2: + return await self._browse_series_teacher(_segment_id(p1), _segment_id(p2)) + if section == "series" and p1: + return await self._browse_series(_segment_id(p1)) + if section == "teachers" and not p1: + return await self._browse_all_teachers() + if section == "teachers" and p1: + return await self._browse_teacher_episodes(_segment_id(p1)) + if section == "categories" and not p1: + return await self._browse_category_list() + if section == "categories" and p1: + return await self._browse_category(_segment_id(p1)) + if section == "recent": + return await self._browse_recent() + return [] + + def _browse_root(self) -> list[BrowseFolder]: + """Return the four top-level browse folders.""" + sections = [ + ("series", "Browse by Series", None, False), + ("teachers", "Browse by Teacher", None, False), + ("categories", "Browse by Topic", None, False), + ("recent", "Recent Shiurim", None, True), + ] + return [ + BrowseFolder( + item_id=item_id, + provider=self.instance_id, + path=f"{self.domain}://{item_id}", + name=name, + translation_key=translation_key, + is_playable=playable, + ) + for item_id, name, translation_key, playable in sections + ] + + async def _browse_series(self, series_id: str) -> list[Podcast | BrowseFolder]: + """Return the series Podcast plus teacher sub-folders for a given series ID.""" + teacher_folders, series_list = await asyncio.gather( + self._browse_series_teachers(series_id), + self._fetch_series_list(), + ) + series_raw = next( + (s for s in series_list if str(s.get("ID") or s.get("seriesID") or "") == series_id), + None, + ) + items: list[Podcast | BrowseFolder] = [] + if series_raw: + items.append(_series_to_podcast(series_raw, self.instance_id)) + items.extend(teacher_folders) + return items + + async def _browse_all_series(self) -> list[BrowseFolder]: + """Return all series as browse folders (each expands to teacher sub-folders).""" + series_list = await self._fetch_series_list() + folders = [] + for s in series_list: + sid = str(s.get("ID") or s.get("seriesID") or "") + if not sid: + continue + name = s.get("name") or "Unknown Series" + folders.append( + BrowseFolder( + item_id=sid, + provider=self.instance_id, + path=f"{self.domain}://series/{_path_segment(name, sid)}", + name=name, + is_playable=False, + ) + ) + folders.sort(key=lambda f: f.name.lower()) + return folders + + async def _browse_series_teachers(self, series_id: str) -> list[BrowseFolder]: + """Return teacher sub-folders for a series, derived from search facets.""" + data, series_list = await asyncio.gather( + self._api_get("search/get", searchTerm="", seriesID=series_id, getFacets=True), + self._fetch_series_list(), + ) + if not data or not isinstance(data, dict): + return [] + + series_raw = next( + (s for s in series_list if str(s.get("ID") or s.get("seriesID") or "") == series_id), + None, + ) + series_name = (series_raw.get("name") or "") if series_raw else "" + series_seg = _path_segment(series_name, series_id) + + teachers = (data.get("facet_counts") or {}).get("facet_fields", {}).get("teachers", []) + folders = [] + for t in teachers: + tid = str(t.get("TeacherId") or "") + name = t.get("TeacherName") or "" + count = t.get("Match", 0) + if not tid or not name or count == 0: + continue + folders.append( + BrowseFolder( + item_id=f"st_{series_id}_{tid}", + provider=self.instance_id, + path=f"{self.domain}://series/{series_seg}/{_path_segment(name, tid)}", + name=f"{name} ({count})", + is_playable=False, + ) + ) + return folders + + async def _browse_series_teacher( + self, series_id: str, teacher_id: str + ) -> list[Podcast | PodcastEpisode]: + """Return a subscribable st_ Podcast followed by its episodes.""" + episodes, teachers_map, series_list = await asyncio.gather( + self._browse_series_teacher_episodes(series_id, teacher_id), + self._fetch_teachers_map(), + self._fetch_series_list(), + ) + st_podcast = _build_st_podcast( + series_id, teacher_id, teachers_map, series_list, self.instance_id + ) + return [st_podcast, *episodes] + + async def _browse_series_teacher_episodes( + self, series_id: str, teacher_id: str + ) -> list[PodcastEpisode]: + """Return episodes for one teacher within a series.""" + return await self._fetch_episodes_paged( + seriesID=series_id, teacherID=teacher_id, parent_series_id=series_id + ) + + async def _browse_all_teachers(self) -> list[Podcast]: + """Return all teachers as subscribable Podcast items (virtual podcasts with t_ prefix).""" + teachers_map = await self._fetch_teachers_map() + if not teachers_map: + return [] + + podcasts = [] + for tid, t in teachers_map.items(): + name = t.get("fullName") or "" + count = t.get("shiurCount") or 0 + if not tid or not name or t.get("isHidden") or count == 0: + continue + image_url = t.get("imageURL") or "" + podcasts.append( + Podcast( + item_id=f"t_{tid}", + provider=self.instance_id, + name=f"{name} ({count})", + metadata=MediaItemMetadata( + images=UniqueList(_make_images(image_url, self.instance_id)) or None, + ), + provider_mappings={ + ProviderMapping( + item_id=f"t_{tid}", + provider_domain="yutorah", + provider_instance=self.instance_id, + ) + }, + ) + ) + return podcasts + + async def _browse_teacher_episodes(self, teacher_id: str) -> list[PodcastEpisode]: + """Return all episodes by a teacher using paginated search.""" + return await self._fetch_episodes_paged(teacherID=teacher_id) + + async def _browse_category_list(self) -> list[BrowseFolder]: + """Return subcategories as browse folders. + + browse/categories returns top-level categories each with a subCategories list. + Subcategory IDs are what landingpage/landing accepts as search targets. + """ + data = await self._api_get("browse/categories", favoritesOnly=False) + if not data or not isinstance(data, list): + return [] + + folders: list[BrowseFolder] = [] + for cat in data: + cat_name = cat.get("name") or "" + for sub in cat.get("subCategories") or []: + sub_id = str(sub.get("ID") or sub.get("id") or "") + sub_name = sub.get("name") or "" + if not sub_id or not sub_name: + continue + label = f"{cat_name} — {sub_name}" if cat_name else sub_name + folders.append( + BrowseFolder( + item_id=f"sub_{sub_id}", + provider=self.instance_id, + path=f"{self.domain}://categories/{_path_segment(label, sub_id)}", + name=label, + is_playable=False, + ) + ) + return folders + + async def _browse_category(self, category_id: str) -> list[Podcast | BrowseFolder]: + """Return series with shiurim in a subcategory using landingpage/landing. + + Uses type=subcategory which requires no authentication, then deduplicates + by series so the user sees which podcasts cover that topic. + """ + data = await self._api_get("landingpage/landing", type="subcategory", value=category_id) + if not data or not isinstance(data, dict): + return [] + + series_list = await self._fetch_series_list() + series_by_id = {str(s.get("ID") or s.get("seriesID") or ""): s for s in series_list} + + seen_series: set[str] = set() + results: list[Podcast | BrowseFolder] = [] + for key in ("recentlyAddedShiurim", "topShiurim", "featuredShiurim"): + for raw in data.get(key) or []: + sid = str(raw.get("shiurSeries") or "") + if sid and sid not in seen_series: + seen_series.add(sid) + results.append( + _series_or_stub_podcast(sid, raw, series_by_id, self.instance_id) + ) + return results + + async def _browse_recent(self) -> list[PodcastEpisode]: + """Return recently uploaded shiurim from the homepage endpoint (no auth needed).""" + data = await self._api_get("homepage/details") + episodes: list[PodcastEpisode] = [] + for i, raw in enumerate((data or {}).get("recentlyUploaded") or []): + episode = _shiur_to_episode(raw, i, self.instance_id) + if episode: + episodes.append(episode) + return episodes diff --git a/music_assistant/providers/yutorah/constants.py b/music_assistant/providers/yutorah/constants.py new file mode 100644 index 0000000000..0d2dd51d30 --- /dev/null +++ b/music_assistant/providers/yutorah/constants.py @@ -0,0 +1,24 @@ +"""Constants for the YuTorah music provider.""" + +from music_assistant_models.enums import ProviderFeature + +API_BASE = "https://yutorah.org/api/" +YUTORAH_BASE = "https://www.yutorah.org" + +# Headers that mirror the official YuTorah Android app +API_HEADERS = { + "Accept": "application/json", + "os": "android", + "app-version": "1.3.4", + "os-version": "30", + "User-Agent": "YuTorah/1.3.4 (Android 11)", +} + +SUPPORTED_FEATURES = { + ProviderFeature.BROWSE, + ProviderFeature.SEARCH, +} + +# search/get returns 30 results per page; start is a 1-based page number +PAGE_SIZE = 30 +MAX_EPISODES = 500 diff --git a/music_assistant/providers/yutorah/helpers.py b/music_assistant/providers/yutorah/helpers.py new file mode 100644 index 0000000000..be42cbf235 --- /dev/null +++ b/music_assistant/providers/yutorah/helpers.py @@ -0,0 +1,374 @@ +"""Pure helper functions for the YuTorah music provider.""" + +from __future__ import annotations + +from typing import Any + +from music_assistant_models.enums import ContentType, ImageType, LinkType, MediaType +from music_assistant_models.media_items import ( + Artist, + AudioFormat, + ItemMapping, + MediaItemImage, + MediaItemLink, + MediaItemMetadata, + Podcast, + PodcastEpisode, + ProviderMapping, + Track, +) +from music_assistant_models.unique_list import UniqueList + +from .constants import YUTORAH_BASE + + +def _series_to_podcast(series: dict[str, Any], instance_id: str) -> Podcast: + """Convert a YuSeriesFromBrowse dict to a MA Podcast.""" + series_id = str(series.get("ID") or series.get("seriesID") or "") + name = series.get("name") or "Unknown Series" + image_url = series.get("imageURL") or "" + shiur_count = series.get("shiurCount") or series.get("numShiurim") + images = _make_images(image_url, instance_id) + + return Podcast( + item_id=series_id, + provider=instance_id, + name=name, + publisher=series.get("middleTierName") or "", + total_episodes=int(shiur_count) if shiur_count else None, + provider_mappings={ + ProviderMapping( + item_id=series_id, + provider_domain="yutorah", + provider_instance=instance_id, + url=f"{YUTORAH_BASE}/series/{_slugify(name)}", + ) + }, + metadata=MediaItemMetadata( + images=UniqueList(images) if images else None, + links={ + MediaItemLink( + type=LinkType.WEBSITE, + url=f"{YUTORAH_BASE}/series/{_slugify(name)}", + ) + } + if name + else None, + ), + ) + + +def _shiur_to_episode( + shiur: dict[str, Any], + position: int, + instance_id: str, + parent_series_id: str | None = None, +) -> PodcastEpisode | None: + """Convert a YuShiur or YuSearchDoc dict to a MA PodcastEpisode. + + Returns None if the shiur has no playable MP3 URL. + """ + # Field names differ between shiur/details (YuShiur) and search/get (YuSearchDoc) + shiur_id = str(shiur.get("shiurID") or shiur.get("shiurid") or "") + if not shiur_id: + return None + + media_type = shiur.get("shiurMediaType") or shiur.get("mediatypename") or "" + + # Only handle audio (MP3); skip video/PDF/HTML unconditionally. + # Empty string is for legacy content predating the shiurMediaType attribute. + if media_type.upper() not in ("MP3", "AUDIO", ""): + return None + + mp3_url = shiur.get("shiurFileURL") or shiur.get("shiurdownloadurl") or "" + if not mp3_url: + return None + + title = shiur.get("shiurTitle") or shiur.get("shiurtitle") or "Untitled Shiur" + description = shiur.get("shiurDescription") or shiur.get("shiurdescription") or "" + date_str = shiur.get("shiurDate") or shiur.get("shiurdate") or "" + # search/get returns 'duration' as integer minutes; shiur/details uses 'shiurLength' string + raw_dur = shiur.get("duration") + if raw_dur is not None: + duration_sec = int(raw_dur) * 60 + else: + shiur_len = shiur.get("shiurLength") or shiur.get("durationformatted") or "" + duration_sec = _parse_duration(shiur_len) + + teacher_id, teacher_name, image_url = _extract_teacher_info(shiur) + + series_id = parent_series_id or str(shiur.get("shiurSeries") or "") + podcast_ref = ItemMapping( + item_id=series_id or f"teacher_{teacher_id}", + provider=instance_id, + name=shiur.get("shiurSeriesName") or teacher_name or "YuTorah", + media_type=MediaType.PODCAST, + ) + + if date_str: + description = f"{date_str} — {description}" if description else date_str + + images = _make_images(image_url, instance_id) + links: set[MediaItemLink] = set() + if shiur_id: + links.add( + MediaItemLink( + type=LinkType.WEBSITE, + url=f"{YUTORAH_BASE}/lectures/{shiur_id}/", + ) + ) + + return PodcastEpisode( + item_id=shiur_id, + provider=instance_id, + name=title, + position=position, + podcast=podcast_ref, + duration=duration_sec, + provider_mappings={ + ProviderMapping( + item_id=shiur_id, + provider_domain="yutorah", + provider_instance=instance_id, + audio_format=AudioFormat(content_type=ContentType.MP3), + # Store the direct MP3 URL in details to avoid a re-fetch at play time + details=mp3_url, + url=f"{YUTORAH_BASE}/lectures/{shiur_id}/", + ) + }, + metadata=MediaItemMetadata( + description=description or None, + images=UniqueList(images) if images else None, + links=links or None, + ), + ) + + +def _shiur_to_track( + shiur: dict[str, Any], + position: int, + instance_id: str, +) -> Track | None: + """Convert a shiur dict to a MA Track for artist top-tracks display. + + Returns None if the shiur has no playable MP3 URL. + """ + shiur_id = str(shiur.get("shiurID") or shiur.get("shiurid") or "") + if not shiur_id: + return None + + media_type_str = shiur.get("shiurMediaType") or shiur.get("mediatypename") or "" + if media_type_str.upper() not in ("MP3", "AUDIO", ""): + return None + + mp3_url = shiur.get("shiurFileURL") or shiur.get("shiurdownloadurl") or "" + if not mp3_url: + return None + + title = shiur.get("shiurTitle") or shiur.get("shiurtitle") or "Untitled Shiur" + raw_dur = shiur.get("duration") + if raw_dur is not None: + duration_sec = int(raw_dur) * 60 + else: + shiur_len = shiur.get("shiurLength") or shiur.get("durationformatted") or "" + duration_sec = _parse_duration(shiur_len) + + teacher_id, teacher_name, image_url = _extract_teacher_info(shiur) + + images = _make_images(image_url, instance_id) + artists: UniqueList[Artist | ItemMapping] = ( + UniqueList( + [ + ItemMapping( + item_id=teacher_id, + provider=instance_id, + name=teacher_name, + media_type=MediaType.ARTIST, + ) + ] + ) + if teacher_id + else UniqueList() + ) + return Track( + item_id=shiur_id, + provider=instance_id, + name=title, + duration=duration_sec, + track_number=position + 1, + artists=artists, + provider_mappings={ + ProviderMapping( + item_id=shiur_id, + provider_domain="yutorah", + provider_instance=instance_id, + audio_format=AudioFormat(content_type=ContentType.MP3), + details=mp3_url, + url=f"{YUTORAH_BASE}/lectures/{shiur_id}/", + ) + }, + metadata=MediaItemMetadata( + images=UniqueList(images) if images else None, + ), + ) + + +def _extract_teacher_info(shiur: dict[str, Any]) -> tuple[str, str, str]: + """Extract (teacher_id, teacher_name, image_url) from a shiur dict. + + Handles both YuShiur (shiurTeachers list) and YuSearchDoc (flat fields). + """ + teachers = shiur.get("shiurTeachers") + if teachers and isinstance(teachers, list): + t = teachers[0] + teacher_id = str(t.get("teacherID") or "") + teacher_name = t.get("teacherName") or "" + image_url = t.get("teacherPhotoURL") or t.get("teacherAlbumURL") or "" + else: + teacher_id = str(shiur.get("teacherid") or "") + teacher_name = shiur.get("teacherfullname") or "" + photo = shiur.get("PHOTO") or shiur.get("photo") or "" + if photo and not photo.startswith("http"): + photo = f"https://cdnyutorah.cachefly.net/_images/roshei_yeshiva/{photo}" + image_url = photo + return teacher_id, teacher_name, image_url + + +def _make_images(url: str, provider_id: str) -> list[MediaItemImage]: + """Build a list with one thumbnail MediaItemImage, or empty if url is unusable.""" + if not url or not url.startswith("http"): + return [] + return [ + MediaItemImage( + type=ImageType.THUMB, + path=url, + provider=provider_id, + remotely_accessible=True, + ) + ] + + +def _parse_duration(s: str) -> int: + """Convert duration string (HH:MM:SS or MM:SS or seconds) to int seconds.""" + if not s: + return 0 + s = s.split(".")[0].strip() + parts = s.split(":") + try: + if len(parts) == 3: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) + if len(parts) == 2: + return int(parts[0]) * 60 + int(parts[1]) + return int(parts[0]) + except (ValueError, IndexError): + return 0 + + +def _path_segment(name: str, item_id: str) -> str: + """Build a browse path segment encoding display name and ID as 'name|id'. + + The frontend can display the name portion; the provider parses the ID for API calls. + Mirrors the pattern used by the Apple Music provider. + """ + safe = name.replace("|", "-").replace("/", "-").strip() + return f"{safe}|{item_id}" + + +def _segment_id(segment: str) -> str: + """Extract the numeric ID from a 'name|id' path segment, or return segment unchanged.""" + return segment.rsplit("|", 1)[-1] if "|" in segment else segment + + +def _slugify(name: str) -> str: + """Create a URL-safe slug from a display name. + + Used only for building decorative external website URLs (e.g. yutorah.org/series/daf-yomi). + """ + result = name.lower() + for ch in ",'\"()[]{}": + result = result.replace(ch, "") + for ch in " ;:.!?\u2014\u2013\t": + result = result.replace(ch, "-") + while "--" in result: + result = result.replace("--", "-") + return result.strip("-") + + +def _build_st_podcast( + series_id: str, + teacher_id: str, + teachers_map: dict[str, dict[str, Any]], + series_list: list[dict[str, Any]], + instance_id: str, +) -> Podcast: + """Build a virtual series+teacher Podcast for a combined st_ podcast ID.""" + t = teachers_map.get(teacher_id) or {} + teacher_name = t.get("fullName") or f"Teacher {teacher_id}" + image_url = t.get("imageURL") or "" + series_name = next( + ( + str(s.get("name") or "") + for s in series_list + if str(s.get("ID") or s.get("seriesID") or "") == series_id + ), + "", + ) + podcast_id = f"st_{series_id}_{teacher_id}" + podcast_name = f"{series_name} — {teacher_name}" if series_name else teacher_name + return Podcast( + item_id=podcast_id, + provider=instance_id, + name=podcast_name, + metadata=MediaItemMetadata( + images=UniqueList(_make_images(image_url, instance_id)) or None, + ), + provider_mappings={ + ProviderMapping( + item_id=podcast_id, + provider_domain="yutorah", + provider_instance=instance_id, + ) + }, + ) + + +def _series_or_stub_podcast( + sid: str, + raw: dict[str, Any], + series_by_id: dict[str, Any], + instance_id: str, +) -> Podcast: + """Return a full Podcast for a known series, or a minimal stub for an unknown one.""" + if sid in series_by_id: + return _series_to_podcast(series_by_id[sid], instance_id) + return Podcast( + item_id=sid, + provider=instance_id, + name=raw.get("shiurSeriesName") or sid, + provider_mappings={ + ProviderMapping( + item_id=sid, + provider_domain="yutorah", + provider_instance=instance_id, + ) + }, + ) + + +def _extract_docs(data: Any) -> list[dict[str, Any]]: + """Extract the list of shiur documents from a search/get API response. + + The search/get endpoint returns a Solr-style response. Documents are under + response.docs; the facet data is under facet_counts.facet_fields. + """ + if not data: + return [] + if isinstance(data, list): + return data + if isinstance(data, dict): + inner = data.get("response") + if isinstance(inner, dict): + docs = inner.get("docs") + if isinstance(docs, list): + return docs + return [] diff --git a/music_assistant/providers/yutorah/icon.svg b/music_assistant/providers/yutorah/icon.svg new file mode 100644 index 0000000000..d5f1416305 --- /dev/null +++ b/music_assistant/providers/yutorah/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/music_assistant/providers/yutorah/icon_monochrome.svg b/music_assistant/providers/yutorah/icon_monochrome.svg new file mode 100644 index 0000000000..e7eeba10ec --- /dev/null +++ b/music_assistant/providers/yutorah/icon_monochrome.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/music_assistant/providers/yutorah/manifest.json b/music_assistant/providers/yutorah/manifest.json new file mode 100644 index 0000000000..73bf262272 --- /dev/null +++ b/music_assistant/providers/yutorah/manifest.json @@ -0,0 +1,29 @@ +{ + "type": "music", + "domain": "yutorah", + "name": "YuTorah", + "description": "Stream over 400,000 Torah shiurim (audio lectures) from YUTorah Online.", + "stage": "beta", + "codeowners": ["@russlevy"], + "requirements": [], + "multi_instance": false, + "config_entries": [ + { + "key": "label_auth", + "type": "label", + "label": "A free YuTorah account is required. Sign up at yutorah.org." + }, + { + "key": "username", + "type": "string", + "label": "Email address", + "required": true + }, + { + "key": "password", + "type": "secure_string", + "label": "Password", + "required": true + } + ] +} diff --git a/music_assistant/providers/yutorah/provider.py b/music_assistant/providers/yutorah/provider.py new file mode 100644 index 0000000000..c8c66ccea0 --- /dev/null +++ b/music_assistant/providers/yutorah/provider.py @@ -0,0 +1,441 @@ +"""YuTorahProvider class for Music Assistant.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar + +import aiohttp +from music_assistant_models.enums import ( + ContentType, + MediaType, + StreamType, +) +from music_assistant_models.errors import ( + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, + SetupFailedError, +) +from music_assistant_models.media_items import ( + Artist, + AudioFormat, + MediaItemMetadata, + Podcast, + PodcastEpisode, + ProviderMapping, + SearchResults, + Track, +) +from music_assistant_models.streamdetails import StreamDetails +from music_assistant_models.unique_list import UniqueList + +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME +from music_assistant.controllers.cache import use_cache +from music_assistant.models.music_provider import MusicProvider + +from .browse import YuTorahBrowseMixin +from .constants import API_BASE, API_HEADERS, MAX_EPISODES, PAGE_SIZE +from .helpers import ( + _build_st_podcast, + _extract_docs, + _make_images, + _series_to_podcast, + _shiur_to_episode, + _shiur_to_track, +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +_T = TypeVar("_T") + + +class YuTorahProvider(YuTorahBrowseMixin, MusicProvider): # type: ignore[misc] + """Music Assistant provider for YuTorah Online. + + Uses the official mobile app JSON API — no scraping, no Cloudflare issues. + Browse the full series directory, search by any term, and stream any shiur. + """ + + # ----------------------------------------------------------------------- + # Lifecycle + # ----------------------------------------------------------------------- + + async def handle_async_init(self) -> None: + """Authenticate with YuTorah; credentials are required.""" + username = self.config.get_value(CONF_USERNAME) + password = self.config.get_value(CONF_PASSWORD) + if not username or not password: + raise SetupFailedError( + "YuTorah requires a username and password. Sign up free at yutorah.org." + ) + try: + await self._login(str(username), str(password)) + except LoginFailed as exc: + raise SetupFailedError(str(exc)) from exc + + async def _login(self, email: str, password: str) -> None: + """Authenticate with YuTorah and store the user token. + + :raises LoginFailed: if credentials are rejected by the API. + """ + try: + async with self.mass.http_session.post( + f"{API_BASE}login/default", + json={"email": email, "password": password}, + headers=API_HEADERS, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + resp.raise_for_status() + data = await resp.json(content_type=None) + except aiohttp.ClientError as exc: + raise LoginFailed(f"YuTorah login error: {exc}") from exc + + if not (data and data.get("loginSuccess") and data.get("userToken")): + raise LoginFailed("YuTorah login failed — check your email and password.") + + self._user_token: str = data["userToken"] + self.logger.info("YuTorah login successful — full episode access enabled.") + + # ----------------------------------------------------------------------- + # Podcast — series + # ----------------------------------------------------------------------- + + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Return a single Podcast (series or teacher) by its provider ID. + + IDs prefixed with ``st_`` identify a series+teacher virtual-podcast; + IDs prefixed with ``t_`` identify a teacher virtual-podcast; + plain numeric IDs identify a series. + """ + if prov_podcast_id.startswith("st_"): + _, series_id, teacher_id = prov_podcast_id.split("_", 2) + teachers_map, series_list = await asyncio.gather( + self._fetch_teachers_map(), + self._fetch_series_list(), + ) + return _build_st_podcast( + series_id, teacher_id, teachers_map, series_list, self.instance_id + ) + + if prov_podcast_id.startswith("t_"): + teacher_id = prov_podcast_id[2:] + teachers_map = await self._fetch_teachers_map() + t = teachers_map.get(teacher_id) or {} + name = t.get("fullName") or f"Teacher {teacher_id}" + image_url = t.get("imageURL") or "" + return Podcast( + item_id=prov_podcast_id, + provider=self.instance_id, + name=name, + metadata=MediaItemMetadata( + images=UniqueList(_make_images(image_url, self.instance_id)) or None, + ), + provider_mappings={ + ProviderMapping( + item_id=prov_podcast_id, + provider_domain="yutorah", + provider_instance=self.instance_id, + ) + }, + ) + + series_list = await self._fetch_series_list() + for series in series_list: + sid = str(series.get("ID") or series.get("seriesID") or "") + if sid == str(prov_podcast_id): + return _series_to_podcast(series, self.instance_id) + + raise MediaNotFoundError(f"YuTorah series {prov_podcast_id} not found") + + async def get_podcast_episodes( + self, + prov_podcast_id: str, + ) -> AsyncGenerator[PodcastEpisode, None]: + """Yield shiurim for a series using the paginated search/get endpoint.""" + if prov_podcast_id.startswith("st_"): + _, series_id, teacher_id = prov_podcast_id.split("_", 2) + for ep in await self._browse_series_teacher_episodes(series_id, teacher_id): + yield ep + return + + if prov_podcast_id.startswith("t_"): + teacher_id = prov_podcast_id[2:] + for ep in await self._fetch_episodes_paged(teacherID=teacher_id): + yield ep + else: + for ep in await self._fetch_episodes_paged( + seriesID=prov_podcast_id, parent_series_id=prov_podcast_id + ): + yield ep + + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """Return a single PodcastEpisode by shiur ID via shiur/details.""" + data = await self._api_get("shiur/details", shiurID=prov_episode_id) + if not data or not isinstance(data, dict): + raise MediaNotFoundError(f"YuTorah shiur {prov_episode_id} not found") + episode = _shiur_to_episode(data, 0, self.instance_id) + if not episode: + raise MediaNotFoundError(f"YuTorah shiur {prov_episode_id} has no playable audio") + return episode + + async def get_track(self, prov_track_id: str) -> Track: + """Return a single shiur as a Track by its shiurID. + + Required because SearchResults has no podcast_episodes field, so search + returns shiurim as Track objects; MA calls get_track when the user opens one. + """ + data = await self._api_get("shiur/details", shiurID=prov_track_id) + if not data or not isinstance(data, dict): + raise MediaNotFoundError(f"YuTorah: shiur {prov_track_id} not found") + track = _shiur_to_track(data, 0, self.instance_id) + if not track: + raise MediaNotFoundError(f"YuTorah: shiur {prov_track_id} has no playable MP3") + return track + + async def get_artist(self, prov_artist_id: str) -> Artist: + """Return a teacher as an Artist by their teacher ID.""" + teachers_map = await self._fetch_teachers_map() + t = teachers_map.get(prov_artist_id) or {} + name = t.get("fullName") or f"Teacher {prov_artist_id}" + image_url = t.get("imageURL") or "" + return Artist( + item_id=prov_artist_id, + provider=self.instance_id, + name=name, + metadata=MediaItemMetadata( + images=UniqueList(_make_images(image_url, self.instance_id)) or None, + ), + provider_mappings={ + ProviderMapping( + item_id=prov_artist_id, + provider_domain="yutorah", + provider_instance=self.instance_id, + ) + }, + ) + + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + """Return shiurim by a teacher as Tracks.""" + return await self._paginate_search( + lambda raw, i: _shiur_to_track(raw, i, self.instance_id), + teacherID=prov_artist_id, + ) + + # ----------------------------------------------------------------------- + # Streaming + # ----------------------------------------------------------------------- + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Return stream details for a shiur. + + Calls shiur/details to retrieve the direct MP3 download URL. + """ + data = await self._api_get("shiur/details", shiurID=item_id) + mp3_url = (data.get("shiurFileURL") or "") if data and isinstance(data, dict) else "" + + if not mp3_url: + raise MediaNotFoundError(f"YuTorah: no MP3 URL found for shiur {item_id}") + + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=AudioFormat(content_type=ContentType.MP3), + media_type=media_type, + stream_type=StreamType.HTTP, + path=mp3_url, + allow_seek=True, + can_seek=True, + ) + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search( + self, + search_query: str, + media_types: list[MediaType], + limit: int = 25, + ) -> SearchResults: + """Search YuTorah for shiurim and series/teacher podcasts. + + Uses search/get for full-text search. Individual shiurim are returned as Tracks + (SearchResults has no podcast_episodes field). Series and teachers are returned + as Podcasts — teachers use the ``t_`` virtual podcast prefix. + """ + results = SearchResults() + + data = await self._api_get("search/get", searchTerm=search_query or "", getFacets=True) + if not data: + return results + + facet_fields = (data.get("facet_counts") or {}).get("facet_fields") or {} + + if MediaType.TRACK in media_types: + docs = _extract_docs(data) + tracks: list[Track] = [] + for i, raw in enumerate(docs[:limit]): + track = _shiur_to_track(raw, i, self.instance_id) + if track: + tracks.append(track) + results.tracks = tracks + + if MediaType.PODCAST in media_types: + podcasts: list[Podcast] = [] + for facet in (facet_fields.get("series") or [])[:limit]: + sid = str(facet.get("SeriesId") or "") + name = facet.get("SeriesName") or "" + if not sid or not name: + continue + podcasts.append( + Podcast( + item_id=sid, + provider=self.instance_id, + name=name, + provider_mappings={ + ProviderMapping( + item_id=sid, + provider_domain="yutorah", + provider_instance=self.instance_id, + ) + }, + ) + ) + results.podcasts = podcasts + + if MediaType.ARTIST in media_types: + teachers_map = await self._fetch_teachers_map() + artists: list[Artist] = [] + for facet in (facet_fields.get("teachers") or [])[:limit]: + tid = str(facet.get("TeacherId") or "") + name = facet.get("TeacherName") or "" + if not tid or not name: + continue + teacher_data = teachers_map.get(tid) or {} + image_url = teacher_data.get("imageURL") or "" + images = _make_images(image_url, self.instance_id) + artists.append( + Artist( + item_id=tid, + provider=self.instance_id, + name=name, + metadata=MediaItemMetadata( + images=UniqueList(images) if images else None, + ), + provider_mappings={ + ProviderMapping( + item_id=tid, + provider_domain="yutorah", + provider_instance=self.instance_id, + ) + }, + ) + ) + results.artists = artists + + return results + + # ----------------------------------------------------------------------- + # Internal — API calls + # ----------------------------------------------------------------------- + + async def _fetch_episodes_paged( + self, + parent_series_id: str | None = None, + **filter_params: str, + ) -> list[PodcastEpisode]: + """Fetch episodes from search/get with automatic pagination. + + Passes any keyword args as extra filter params (e.g. seriesID, teacherID). + """ + return await self._paginate_search( + lambda raw, i: _shiur_to_episode(raw, i, self.instance_id, parent_series_id), + **filter_params, + ) + + async def _paginate_search( + self, + converter: Callable[[dict[str, Any], int], _T | None], + **filter_params: str, + ) -> list[_T]: + """Paginate search/get results, applying converter to each doc. + + :param converter: Called with (raw_doc, current_count); returns an item or None to skip. + :param filter_params: Extra filter kwargs forwarded to the API (e.g. seriesID, teacherID). + """ + results: list[_T] = [] + page = 1 + while len(results) < MAX_EPISODES: + extra: dict[str, Any] = ( + {"getFacets": True} if page == 1 else {"getFacets": False, "start": page} + ) + data = await self._api_get("search/get", searchTerm="", **filter_params, **extra) + docs = _extract_docs(data) + if not docs: + break + for raw in docs: + item = converter(raw, len(results)) + if item is not None: + results.append(item) + if len(docs) < PAGE_SIZE: + break + page += 1 + return results + + async def _api_get(self, endpoint: str, **params: Any) -> Any: + """Make a GET request to the YuTorah JSON API and return parsed JSON. + + Returns None for 404 responses. Raises ProviderUnavailableError for other errors. + """ + str_params = { + k: str(v).lower() if isinstance(v, bool) else str(v) + for k, v in params.items() + if v is not None + } + # The YuTorah API accepts the auth token as a query parameter named + # "userToken" (confirmed by OkHttp interceptor in APK source). Sending + # it only as an HTTP header does not work — the server ignores it. + str_params["userToken"] = self._user_token + safe_params = {k: v for k, v in str_params.items() if k != "userToken"} + try: + async with self.mass.http_session.get( + f"{API_BASE}{endpoint}", + params=str_params, + headers=API_HEADERS, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 404: + return None + resp.raise_for_status() + raw_text = await resp.text() + return json.loads(raw_text) + except aiohttp.ClientResponseError as exc: + raise ProviderUnavailableError( + f"YuTorah API {endpoint} failed (params={safe_params}): {exc}" + ) from exc + except aiohttp.ClientError as exc: + raise ProviderUnavailableError( + f"YuTorah API {endpoint} network error (params={safe_params}): {exc}" + ) from exc + except json.JSONDecodeError as exc: + raise ProviderUnavailableError( + f"YuTorah API {endpoint} returned invalid JSON: {exc}" + ) from exc + + @use_cache(3600) + async def _fetch_series_list(self) -> list[dict[str, Any]]: + """Fetch the full list of curated series from browse/series, cached for 1 hour.""" + data = await self._api_get("browse/series", favoritesOnly=False) + return data if isinstance(data, list) else [] + + @use_cache(3600) + async def _fetch_teachers_map(self) -> dict[str, dict[str, Any]]: + """Fetch and cache all teachers as a dict keyed by teacher ID string.""" + data = await self._api_get("browse/teachers", favoritesOnly=False) + if not isinstance(data, list): + return {} + return {str(t.get("ID") or ""): t for t in data if t.get("ID")} diff --git a/tests/providers/test_yutorah.py b/tests/providers/test_yutorah.py new file mode 100644 index 0000000000..8e16d4fc70 --- /dev/null +++ b/tests/providers/test_yutorah.py @@ -0,0 +1,644 @@ +"""Tests for the YuTorah provider.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest +from music_assistant_models.enums import MediaType +from music_assistant_models.errors import ( + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, + SetupFailedError, +) +from music_assistant_models.media_items import BrowseFolder, Podcast + +from music_assistant.providers.yutorah import YuTorahProvider + +# --------------------------------------------------------------------------- +# Shared sample data +# --------------------------------------------------------------------------- + +SAMPLE_SHIUR: dict[str, Any] = { + "shiurID": "12345", + "shiurTitle": "Test Shiur", + "shiurFileURL": "https://cdn.example.com/test.mp3", + "shiurLength": "45:00", + "teacherid": 7, + "teacherfullname": "Rabbi C", + "photo": "https://cdn.example.com/c.jpg", + "shiurSeries": "100", + "shiurSeriesName": "Daf Yomi", +} + +# Flat format returned by search/get +SAMPLE_SEARCH_DOC: dict[str, Any] = { + "shiurID": "12345", + "shiurtitle": "Test Shiur", + "shiurdownloadurl": "https://cdn.example.com/test.mp3", + "duration": 45, + "teacherid": "7", + "teacherfullname": "Rabbi C", + "photo": "https://cdn.example.com/c.jpg", +} + +SAMPLE_SERIES: dict[str, Any] = { + "ID": "100", + "name": "Daf Yomi", + "imageURL": "https://cdn.example.com/daf.jpg", + "shiurCount": 200, +} + +SAMPLE_TEACHER: dict[str, Any] = { + "ID": 7, + "fullName": "Rabbi C", + "imageURL": "https://cdn.example.com/c.jpg", + "shiurCount": 100, + "isHidden": False, +} + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mass_mock() -> AsyncMock: + """Return a mock MusicAssistant instance.""" + mass = AsyncMock() + mass.http_session = AsyncMock() + mass.cache.get = AsyncMock(return_value=None) + mass.cache.set = AsyncMock() + return mass + + +@pytest.fixture +def provider(mass_mock: AsyncMock) -> YuTorahProvider: + """Return a YuTorahProvider instance with a pre-set auth token.""" + manifest = MagicMock() + manifest.domain = "yutorah" + config = MagicMock() + config.get_value.return_value = None + + with patch("music_assistant.models.provider.logging.Logger.setLevel"): + prov = YuTorahProvider(mass_mock, manifest, config) + + prov._user_token = "test_token" + return prov + + +@pytest.fixture +def auth_provider(provider: YuTorahProvider) -> YuTorahProvider: + """Return a provider with a mock authentication token set.""" + provider._user_token = "test_token" + return provider + + +# --------------------------------------------------------------------------- +# handle_async_init +# --------------------------------------------------------------------------- + + +async def test_handle_async_init_no_credentials_raises(provider: YuTorahProvider) -> None: + """Without credentials, SetupFailedError is raised.""" + provider.config.get_value.return_value = None # type: ignore[attr-defined] + with pytest.raises(SetupFailedError): + await provider.handle_async_init() + + +async def test_handle_async_init_with_credentials_calls_login( + provider: YuTorahProvider, +) -> None: + """With username and password configured, _login is called.""" + + def get_value(key: str) -> str | None: + return {"username": "user@example.com", "password": "secret"}.get(key) + + provider.config.get_value.side_effect = get_value # type: ignore[attr-defined] + with patch.object(provider, "_login", new=AsyncMock()) as mock_login: + await provider.handle_async_init() + mock_login.assert_awaited_once_with("user@example.com", "secret") + + +# --------------------------------------------------------------------------- +# _login +# --------------------------------------------------------------------------- + + +async def test_login_raises_on_bad_credentials(provider: YuTorahProvider) -> None: + """Failed login raises LoginFailed.""" + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock( + return_value=MagicMock( + raise_for_status=MagicMock(), + json=AsyncMock(return_value={"loginSuccess": False}), + ) + ) + mock_cm.__aexit__ = AsyncMock(return_value=False) + provider.mass.http_session.post = MagicMock(return_value=mock_cm) # type: ignore[method-assign] + + with pytest.raises(LoginFailed): + await provider._login("bad@example.com", "wrong") + + +# --------------------------------------------------------------------------- +# get_podcast +# --------------------------------------------------------------------------- + + +async def test_get_podcast_series_found(provider: YuTorahProvider) -> None: + """A known series ID returns the matching Podcast.""" + with patch.object(provider, "_fetch_series_list", new=AsyncMock(return_value=[SAMPLE_SERIES])): + podcast = await provider.get_podcast("100") + assert podcast.name == "Daf Yomi" + assert podcast.item_id == "100" + + +async def test_get_podcast_series_not_found_raises( + provider: YuTorahProvider, +) -> None: + """Unknown series ID raises MediaNotFoundError.""" + with ( + patch.object(provider, "_fetch_series_list", new=AsyncMock(return_value=[])), + pytest.raises(MediaNotFoundError), + ): + await provider.get_podcast("999") + + +async def test_get_podcast_teacher_prefix_found(provider: YuTorahProvider) -> None: + """A 't_' prefixed ID looks up the teacher via the cached map and returns a named Podcast.""" + teachers_map = {"7": SAMPLE_TEACHER} + with patch.object(provider, "_fetch_teachers_map", new=AsyncMock(return_value=teachers_map)): + podcast = await provider.get_podcast("t_7") + assert podcast.item_id == "t_7" + assert podcast.name == "Rabbi C" + + +async def test_get_podcast_teacher_prefix_uses_cache(provider: YuTorahProvider) -> None: + """get_podcast('t_X') uses _fetch_teachers_map (cached) not a direct API call.""" + teachers_map = {"7": SAMPLE_TEACHER} + mock_map = AsyncMock(return_value=teachers_map) + mock_api = AsyncMock() + with ( + patch.object(provider, "_fetch_teachers_map", new=mock_map), + patch.object(provider, "_api_get", new=mock_api), + ): + await provider.get_podcast("t_7") + mock_map.assert_awaited_once() + mock_api.assert_not_awaited() + + +async def test_get_podcast_teacher_prefix_not_found_returns_fallback( + provider: YuTorahProvider, +) -> None: + """Unknown teacher ID with 't_' prefix returns a fallback Podcast.""" + with patch.object(provider, "_fetch_teachers_map", new=AsyncMock(return_value={})): + podcast = await provider.get_podcast("t_999") + assert podcast.item_id == "t_999" + assert "999" in podcast.name + + +# --------------------------------------------------------------------------- +# get_podcast_episodes +# --------------------------------------------------------------------------- + + +async def test_get_podcast_episodes_authenticated_series( + auth_provider: YuTorahProvider, +) -> None: + """With a token, series episodes come from paginated search/get.""" + with patch.object(auth_provider, "_api_get", new=AsyncMock(return_value=[SAMPLE_SEARCH_DOC])): + episodes = [ep async for ep in auth_provider.get_podcast_episodes("100")] + assert len(episodes) == 1 + assert episodes[0].item_id == "12345" + + +async def test_get_podcast_episodes_authenticated_teacher_prefix( + auth_provider: YuTorahProvider, +) -> None: + """Teacher-prefixed ID fetches shiurim by teacherID, not seriesID.""" + with patch.object( + auth_provider, "_api_get", new=AsyncMock(return_value=[SAMPLE_SEARCH_DOC]) + ) as mock_api: + episodes = [ep async for ep in auth_provider.get_podcast_episodes("t_7")] + assert len(episodes) == 1 + # The first call should pass teacherID=7, not seriesID + call_kwargs = mock_api.call_args_list[0].kwargs + assert call_kwargs.get("teacherID") == "7" + assert "seriesID" not in call_kwargs + + +# --------------------------------------------------------------------------- +# get_podcast_episode +# --------------------------------------------------------------------------- + + +async def test_get_podcast_episode_success(provider: YuTorahProvider) -> None: + """Valid shiur/details response returns a PodcastEpisode.""" + with patch.object(provider, "_api_get", new=AsyncMock(return_value=SAMPLE_SHIUR)): + ep = await provider.get_podcast_episode("12345") + assert ep.item_id == "12345" + assert ep.name == "Test Shiur" + assert ep.duration == 2700 # 45 min + + +async def test_get_podcast_episode_mp3_url_stored_in_mapping(provider: YuTorahProvider) -> None: + """Direct MP3 URL is cached in ProviderMapping.details to avoid re-fetches.""" + with patch.object(provider, "_api_get", new=AsyncMock(return_value=SAMPLE_SHIUR)): + ep = await provider.get_podcast_episode("12345") + mapping = next(iter(ep.provider_mappings)) + assert mapping.details == "https://cdn.example.com/test.mp3" + + +async def test_get_podcast_episode_not_found_raises(provider: YuTorahProvider) -> None: + """None from API raises MediaNotFoundError.""" + with ( + patch.object(provider, "_api_get", new=AsyncMock(return_value=None)), + pytest.raises(MediaNotFoundError), + ): + await provider.get_podcast_episode("99999") + + +async def test_get_podcast_episode_no_mp3_raises(provider: YuTorahProvider) -> None: + """A shiur dict with no playable URL raises MediaNotFoundError.""" + shiur = {**SAMPLE_SHIUR, "shiurFileURL": ""} + with ( + patch.object(provider, "_api_get", new=AsyncMock(return_value=shiur)), + pytest.raises(MediaNotFoundError), + ): + await provider.get_podcast_episode("12345") + + +# --------------------------------------------------------------------------- +# get_artist +# --------------------------------------------------------------------------- + + +async def test_get_artist_found(provider: YuTorahProvider) -> None: + """Teacher found in the teachers map returns a correctly named Artist.""" + teachers_map = {"7": SAMPLE_TEACHER} + with patch.object(provider, "_fetch_teachers_map", new=AsyncMock(return_value=teachers_map)): + artist = await provider.get_artist("7") + assert artist.item_id == "7" + assert artist.name == "Rabbi C" + + +async def test_get_artist_not_in_map_returns_fallback(provider: YuTorahProvider) -> None: + """Unknown teacher ID returns a fallback Artist with the ID in the name.""" + with patch.object(provider, "_fetch_teachers_map", new=AsyncMock(return_value={})): + artist = await provider.get_artist("999") + assert artist.item_id == "999" + assert "999" in artist.name + + +# --------------------------------------------------------------------------- +# get_artist_toptracks +# --------------------------------------------------------------------------- + + +async def test_get_artist_toptracks_with_token(auth_provider: YuTorahProvider) -> None: + """With a token, shiurim are returned as Track objects with correct artist linkage.""" + with patch.object(auth_provider, "_api_get", new=AsyncMock(return_value=[SAMPLE_SEARCH_DOC])): + tracks = await auth_provider.get_artist_toptracks("7") + assert len(tracks) == 1 + assert tracks[0].item_id == "12345" + assert tracks[0].artists[0].name == "Rabbi C" + + +async def test_get_artist_toptracks_empty_response(auth_provider: YuTorahProvider) -> None: + """Empty API response returns an empty list.""" + with patch.object(auth_provider, "_api_get", new=AsyncMock(return_value=[])): + tracks = await auth_provider.get_artist_toptracks("7") + assert tracks == [] + + +# --------------------------------------------------------------------------- +# get_track +# --------------------------------------------------------------------------- + + +async def test_get_track_success(provider: YuTorahProvider) -> None: + """Valid shiur/details response returns a Track.""" + with patch.object(provider, "_api_get", new=AsyncMock(return_value=SAMPLE_SHIUR)): + track = await provider.get_track("12345") + assert track.item_id == "12345" + assert track.name == "Test Shiur" + + +async def test_get_track_not_found_raises(provider: YuTorahProvider) -> None: + """None from API raises MediaNotFoundError.""" + with ( + patch.object(provider, "_api_get", new=AsyncMock(return_value=None)), + pytest.raises(MediaNotFoundError), + ): + await provider.get_track("99999") + + +async def test_get_track_no_mp3_raises(provider: YuTorahProvider) -> None: + """A shiur dict with no playable URL raises MediaNotFoundError.""" + shiur = {**SAMPLE_SHIUR, "shiurFileURL": ""} + with ( + patch.object(provider, "_api_get", new=AsyncMock(return_value=shiur)), + pytest.raises(MediaNotFoundError), + ): + await provider.get_track("12345") + + +# --------------------------------------------------------------------------- +# get_stream_details +# --------------------------------------------------------------------------- + + +async def test_get_stream_details_success(provider: YuTorahProvider) -> None: + """Valid shiur/details response yields a StreamDetails with the correct URL.""" + api_response = { + "shiurID": 12345, + "shiurFileURL": "https://cdn.example.com/shiur.mp3", + } + with patch.object(provider, "_api_get", new=AsyncMock(return_value=api_response)): + details = await provider.get_stream_details("12345", MediaType.PODCAST_EPISODE) + assert details.path == "https://cdn.example.com/shiur.mp3" + + +async def test_get_stream_details_not_found_raises(provider: YuTorahProvider) -> None: + """None response from _api_get should raise MediaNotFoundError.""" + with ( + patch.object(provider, "_api_get", new=AsyncMock(return_value=None)), + pytest.raises(MediaNotFoundError), + ): + await provider.get_stream_details("99999", MediaType.PODCAST_EPISODE) + + +async def test_get_stream_details_no_url_raises(provider: YuTorahProvider) -> None: + """Empty shiurFileURL should raise MediaNotFoundError.""" + api_response = {"shiurID": 12345, "shiurFileURL": ""} + with ( + patch.object(provider, "_api_get", new=AsyncMock(return_value=api_response)), + pytest.raises(MediaNotFoundError), + ): + await provider.get_stream_details("12345", MediaType.PODCAST_EPISODE) + + +# --------------------------------------------------------------------------- +# search +# --------------------------------------------------------------------------- + + +async def test_search_authenticated_returns_podcasts_and_artists( + auth_provider: YuTorahProvider, +) -> None: + """Authenticated search builds Podcasts from series facets and Artists from teacher facets.""" + api_response = { + "facet_counts": { + "facet_fields": { + "series": [{"SeriesId": "100", "SeriesName": "Daf Yomi"}], + "teachers": [{"TeacherId": "7", "TeacherName": "Rabbi C"}], + } + } + } + teachers_map = {"7": SAMPLE_TEACHER} + with ( + patch.object(auth_provider, "_api_get", new=AsyncMock(return_value=api_response)), + patch.object( + auth_provider, "_fetch_teachers_map", new=AsyncMock(return_value=teachers_map) + ), + ): + results = await auth_provider.search("test", [MediaType.PODCAST, MediaType.ARTIST]) + assert len(results.podcasts) == 1 + assert results.podcasts[0].name == "Daf Yomi" + assert len(results.artists) == 1 + assert results.artists[0].name == "Rabbi C" + + +async def test_search_authenticated_api_error_returns_empty( + auth_provider: YuTorahProvider, +) -> None: + """None from API when authenticated returns empty SearchResults.""" + with patch.object(auth_provider, "_api_get", new=AsyncMock(return_value=None)): + results = await auth_provider.search("test", [MediaType.PODCAST]) + assert results.podcasts == [] + + +# --------------------------------------------------------------------------- +# browse +# --------------------------------------------------------------------------- + + +async def test_browse_root(provider: YuTorahProvider) -> None: + """Root path returns the four top-level browse folders.""" + results = await provider.browse("yutorah://") + names = [r.name for r in results] + assert "Browse by Series" in names + assert "Browse by Teacher" in names + assert "Browse by Topic" in names + assert "Recent Shiurim" in names + + +async def test_browse_series_list(provider: YuTorahProvider) -> None: + """yutorah://series returns a sorted list of series folders.""" + series_list = [ + {"ID": "100", "name": "Daf Yomi"}, + {"ID": "200", "name": "Tefilla"}, + ] + with patch.object(provider, "_fetch_series_list", new=AsyncMock(return_value=series_list)): + results = await provider.browse("yutorah://series") + assert len(results) == 2 + assert results[0].name == "Daf Yomi" # alphabetically first + + +async def test_browse_series_list_path_format(provider: YuTorahProvider) -> None: + """Series folders use name|id encoding in their browse path.""" + series_list = [{"ID": "100", "name": "Daf Yomi"}] + with patch.object(provider, "_fetch_series_list", new=AsyncMock(return_value=series_list)): + results = await provider.browse("yutorah://series") + folder = results[0] + assert isinstance(folder, BrowseFolder) + assert folder.path == "yutorah://series/Daf Yomi|100" + assert folder.name == "Daf Yomi" + + +async def test_browse_series_authenticated(auth_provider: YuTorahProvider) -> None: + """With a token, drilling into a series by ID returns the Podcast then teacher sub-folders.""" + series_list = [{"ID": "100", "name": "Daf Yomi"}] + facets_response = { + "facet_counts": { + "facet_fields": {"teachers": [{"TeacherId": "7", "TeacherName": "Rabbi C", "Match": 5}]} + } + } + + async def api_side(endpoint: str, **_kw: Any) -> Any: + return facets_response if endpoint == "search/get" else None + + with ( + patch.object(auth_provider, "_fetch_series_list", new=AsyncMock(return_value=series_list)), + patch.object(auth_provider, "_api_get", new=AsyncMock(side_effect=api_side)), + ): + results = await auth_provider.browse("yutorah://series/Daf Yomi|100") + assert len(results) == 2 + assert isinstance(results[0], Podcast) + assert results[0].item_id == "100" + assert "Rabbi C" in results[1].name + assert isinstance(results[1], BrowseFolder) + assert results[1].path == "yutorah://series/Daf Yomi|100/Rabbi C|7" + + +async def test_browse_series_teacher_episodes(auth_provider: YuTorahProvider) -> None: + """yutorah://series// returns a subscribable Podcast then episodes.""" + series_list = [{"ID": "100", "name": "Daf Yomi"}] + teachers_map = {"7": SAMPLE_TEACHER} + + async def api_side(endpoint: str, **_kw: Any) -> Any: + if endpoint == "search/get": + return [SAMPLE_SEARCH_DOC] + return None + + with ( + patch.object(auth_provider, "_fetch_series_list", new=AsyncMock(return_value=series_list)), + patch.object( + auth_provider, "_fetch_teachers_map", new=AsyncMock(return_value=teachers_map) + ), + patch.object(auth_provider, "_api_get", new=AsyncMock(side_effect=api_side)), + ): + results = await auth_provider.browse("yutorah://series/Daf Yomi|100/Rabbi C|7") + assert len(results) == 2 + assert isinstance(results[0], Podcast) + assert results[0].item_id == "st_100_7" + assert "Daf Yomi" in results[0].name + assert "Rabbi C" in results[0].name + assert results[1].item_id == "12345" + + +async def test_browse_teachers(provider: YuTorahProvider) -> None: + """yutorah://teachers returns a subscribable Podcast for each active teacher.""" + teachers_map = {"7": SAMPLE_TEACHER} + with patch.object(provider, "_fetch_teachers_map", new=AsyncMock(return_value=teachers_map)): + results = await provider.browse("yutorah://teachers") + assert len(results) == 1 + podcast = results[0] + assert isinstance(podcast, Podcast) + assert "Rabbi C" in podcast.name + assert podcast.item_id == "t_7" + + +async def test_browse_teachers_uses_cache(provider: YuTorahProvider) -> None: + """yutorah://teachers uses _fetch_teachers_map (cached), not a direct API call.""" + teachers_map = {"7": SAMPLE_TEACHER} + mock_map = AsyncMock(return_value=teachers_map) + mock_api = AsyncMock() + with ( + patch.object(provider, "_fetch_teachers_map", new=mock_map), + patch.object(provider, "_api_get", new=mock_api), + ): + await provider.browse("yutorah://teachers") + mock_map.assert_awaited_once() + mock_api.assert_not_awaited() + + +async def test_browse_teachers_filters_hidden(provider: YuTorahProvider) -> None: + """Hidden teachers are excluded from the teacher list.""" + hidden_teacher = {**SAMPLE_TEACHER, "isHidden": True} + teachers_map = {"7": hidden_teacher} + with patch.object(provider, "_fetch_teachers_map", new=AsyncMock(return_value=teachers_map)): + results = await provider.browse("yutorah://teachers") + assert list(results) == [] + + +async def test_browse_teacher_episodes_authenticated(auth_provider: YuTorahProvider) -> None: + """yutorah://teachers/ with auth uses paginated search.""" + with patch.object(auth_provider, "_api_get", new=AsyncMock(return_value=[SAMPLE_SEARCH_DOC])): + results = await auth_provider.browse("yutorah://teachers/7") + assert len(results) == 1 + + +async def test_browse_categories(provider: YuTorahProvider) -> None: + """yutorah://categories returns sub-category folders with numeric IDs in paths.""" + cat_data = [ + { + "name": "Jewish Law", + "subCategories": [{"ID": "50", "name": "Kashrut"}], + } + ] + with patch.object(provider, "_api_get", new=AsyncMock(return_value=cat_data)): + results = await provider.browse("yutorah://categories") + assert len(results) == 1 + folder = results[0] + assert isinstance(folder, BrowseFolder) + assert "Kashrut" in folder.name + assert "Jewish Law" in folder.name + assert folder.path == "yutorah://categories/Jewish Law — Kashrut|50" + assert "Kashrut" in folder.name + + +async def test_browse_category_by_id(provider: YuTorahProvider) -> None: + """yutorah://categories/ passes the ID directly to the API and returns series.""" + landing_data = { + "recentlyAddedShiurim": [{**SAMPLE_SEARCH_DOC, "shiurSeries": "100"}], + "topShiurim": [], + "featuredShiurim": [], + } + + async def api_side(endpoint: str, **_kw: Any) -> Any: + if endpoint == "landingpage/landing": + return landing_data + if endpoint == "browse/series": + return [SAMPLE_SERIES] + return None + + with patch.object(provider, "_api_get", new=AsyncMock(side_effect=api_side)): + results = await provider.browse("yutorah://categories/Jewish Law — Kashrut|50") + assert len(results) == 1 + assert results[0].name == "Daf Yomi" + + +async def test_browse_recent(provider: YuTorahProvider) -> None: + """yutorah://recent returns recently uploaded shiurim as PodcastEpisodes.""" + homepage_data = {"recentlyUploaded": [SAMPLE_SHIUR]} + with patch.object(provider, "_api_get", new=AsyncMock(return_value=homepage_data)): + results = await provider.browse("yutorah://recent") + assert len(results) == 1 + assert results[0].item_id == "12345" + + +async def test_browse_unknown_path_returns_empty(provider: YuTorahProvider) -> None: + """An unrecognised browse path returns an empty list.""" + results = await provider.browse("yutorah://not-a-real-section") + assert list(results) == [] + + +# --------------------------------------------------------------------------- +# Security: auth token must not appear in error logs / exceptions +# --------------------------------------------------------------------------- + + +async def test_api_error_raises_provider_unavailable( + auth_provider: YuTorahProvider, +) -> None: + """Non-404 HTTP errors raise ProviderUnavailableError.""" + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock( + side_effect=aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=500, message="Server Error" + ) + ) + mock_cm.__aexit__ = AsyncMock(return_value=False) + auth_provider.mass.http_session.get = MagicMock(return_value=mock_cm) # type: ignore[method-assign] + + with pytest.raises(ProviderUnavailableError) as exc_info: + await auth_provider._api_get("shiur/details", shiurID="123") + + assert "test_token" not in str(exc_info.value) + + +async def test_api_404_returns_none(provider: YuTorahProvider) -> None: + """404 responses return None rather than raising.""" + mock_resp = MagicMock() + mock_resp.status = 404 + mock_resp.raise_for_status = MagicMock() + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_cm.__aexit__ = AsyncMock(return_value=False) + provider.mass.http_session.get = MagicMock(return_value=mock_cm) # type: ignore[method-assign] + + result = await provider._api_get("shiur/details", shiurID="123") + assert result is None