music-assistant-server

7 KBPY
streaming.py
7 KB186 lines • python
1"""Streaming operations for KION Music."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any
6
7from music_assistant_models.enums import ContentType, StreamType
8from music_assistant_models.errors import MediaNotFoundError
9from music_assistant_models.media_items import AudioFormat
10from music_assistant_models.streamdetails import StreamDetails
11
12from .constants import CONF_QUALITY, QUALITY_LOSSLESS, RADIO_TRACK_ID_SEP
13
14if TYPE_CHECKING:
15    from yandex_music import DownloadInfo
16
17    from .provider import KionMusicProvider
18
19
20class KionMusicStreamingManager:
21    """Manages KION Music streaming operations."""
22
23    def __init__(self, provider: KionMusicProvider) -> None:
24        """Initialize streaming manager.
25
26        :param provider: The KION Music provider instance.
27        """
28        self.provider = provider
29        self.client = provider.client
30        self.mass = provider.mass
31        self.logger = provider.logger
32
33    def _track_id_from_item_id(self, item_id: str) -> str:
34        """Extract API track ID from item_id (may be track_id@station_id for My Mix)."""
35        if RADIO_TRACK_ID_SEP in item_id:
36            return item_id.split(RADIO_TRACK_ID_SEP, 1)[0]
37        return item_id
38
39    async def get_stream_details(self, item_id: str) -> StreamDetails:
40        """Get stream details for a track.
41
42        :param item_id: Track ID or composite track_id@station_id for My Mix.
43        :return: StreamDetails for the track (item_id preserved for on_streamed).
44        :raises MediaNotFoundError: If stream URL cannot be obtained.
45        """
46        track_id = self._track_id_from_item_id(item_id)
47        track = await self.provider.get_track(item_id)
48        if not track:
49            raise MediaNotFoundError(f"Track {item_id} not found")
50
51        quality = self.provider.config.get_value(CONF_QUALITY)
52        quality_str = str(quality) if quality is not None else None
53        preferred_normalized = (quality_str or "").strip().lower()
54        want_lossless = (
55            QUALITY_LOSSLESS in preferred_normalized or preferred_normalized == QUALITY_LOSSLESS
56        )
57
58        # When user wants lossless, try get-file-info first (FLAC; download-info often MP3 only)
59        if want_lossless:
60            self.logger.debug("Requesting lossless via get-file-info for track %s", track_id)
61            file_info = await self.client.get_track_file_info_lossless(track_id)
62            if file_info:
63                url = file_info.get("url")
64                codec = file_info.get("codec") or ""
65                if url and codec.lower() in ("flac", "flac-mp4"):
66                    content_type = self._get_content_type(codec)
67                    self.logger.debug(
68                        "Stream selected for track %s via get-file-info: codec=%s",
69                        item_id,
70                        codec,
71                    )
72                    return StreamDetails(
73                        item_id=item_id,
74                        provider=self.provider.instance_id,
75                        audio_format=AudioFormat(
76                            content_type=content_type,
77                            bit_rate=0,
78                        ),
79                        stream_type=StreamType.HTTP,
80                        duration=track.duration,
81                        path=url,
82                        can_seek=True,
83                        allow_seek=True,
84                    )
85
86        # Default: use /tracks/.../download-info and select best quality
87        download_infos = await self.client.get_track_download_info(track_id, get_direct_links=True)
88        if not download_infos:
89            raise MediaNotFoundError(f"No stream info available for track {item_id}")
90
91        codecs_available = [
92            (getattr(i, "codec", None), getattr(i, "bitrate_in_kbps", None)) for i in download_infos
93        ]
94        self.logger.debug(
95            "Stream quality for track %s: config quality=%s, available codecs=%s",
96            track_id,
97            quality_str,
98            codecs_available,
99        )
100        selected_info = self._select_best_quality(download_infos, quality_str)
101
102        if not selected_info or not selected_info.direct_link:
103            raise MediaNotFoundError(f"No stream URL available for track {item_id}")
104
105        self.logger.debug(
106            "Stream selected for track %s: codec=%s, bitrate=%s",
107            track_id,
108            getattr(selected_info, "codec", None),
109            getattr(selected_info, "bitrate_in_kbps", None),
110        )
111
112        content_type = self._get_content_type(selected_info.codec)
113        bitrate = selected_info.bitrate_in_kbps or 0
114
115        return StreamDetails(
116            item_id=item_id,
117            provider=self.provider.instance_id,
118            audio_format=AudioFormat(
119                content_type=content_type,
120                bit_rate=bitrate,
121            ),
122            stream_type=StreamType.HTTP,
123            duration=track.duration,
124            path=selected_info.direct_link,
125            can_seek=True,
126            allow_seek=True,
127        )
128
129    def _select_best_quality(
130        self, download_infos: list[Any], preferred_quality: str | None
131    ) -> DownloadInfo | None:
132        """Select the best quality download info.
133
134        :param download_infos: List of DownloadInfo objects.
135        :param preferred_quality: User's preferred quality (e.g. "lossless" or "Lossless (FLAC)").
136        :return: Best matching DownloadInfo or None.
137        """
138        if not download_infos:
139            return None
140
141        # Normalize so we accept "lossless", "Lossless (FLAC)", etc.
142        preferred_normalized = (preferred_quality or "").strip().lower()
143        want_lossless = (
144            QUALITY_LOSSLESS in preferred_normalized or preferred_normalized == QUALITY_LOSSLESS
145        )
146
147        # Sort by bitrate descending
148        sorted_infos = sorted(
149            download_infos,
150            key=lambda x: x.bitrate_in_kbps or 0,
151            reverse=True,
152        )
153
154        # If user wants lossless, prefer flac-mp4 then flac (API formats ~2025)
155        if want_lossless:
156            for codec in ("flac-mp4", "flac"):
157                for info in sorted_infos:
158                    if info.codec and info.codec.lower() == codec:
159                        return info
160            self.logger.warning(
161                "Lossless (FLAC) requested but no FLAC in API response for this "
162                "track; using best available"
163            )
164
165        # Return highest bitrate
166        return sorted_infos[0] if sorted_infos else None
167
168    def _get_content_type(self, codec: str | None) -> ContentType:
169        """Determine content type from codec string.
170
171        :param codec: Codec string from KION API.
172        :return: ContentType enum value.
173        """
174        if not codec:
175            return ContentType.UNKNOWN
176
177        codec_lower = codec.lower()
178        if codec_lower in ("flac", "flac-mp4"):
179            return ContentType.FLAC
180        if codec_lower in ("mp3", "mpeg"):
181            return ContentType.MP3
182        if codec_lower in ("aac", "aac-mp4", "he-aac", "he-aac-mp4"):
183            return ContentType.AAC
184
185        return ContentType.UNKNOWN
186