/
/
/
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