/
/
/
1"""Mappers for converting Music Assistant objects to MSX models."""
2
3from __future__ import annotations
4
5import logging
6from typing import TYPE_CHECKING, Any
7from urllib.parse import quote
8
9from .models import MsxContent, MsxItem, MsxTemplate
10
11if TYPE_CHECKING:
12 from .provider import MSXBridgeProvider
13
14logger = logging.getLogger(__name__)
15
16
17def append_device_param(url: str, device_param: str) -> str:
18 """Append device_id to URL if present."""
19 if not device_param:
20 return url
21 sep = "&" if "?" in url else "?"
22 return f"{url}{sep}{device_param}"
23
24
25def get_image_url(item: Any, provider: MSXBridgeProvider, prefer_proxy: bool = False) -> str | None:
26 """
27 Get an image URL for a media item.
28
29 :param prefer_proxy: Route the image through the MA imageproxy so the URL
30 points at the MA server (rather than a remote CDN). Needed for the
31 party QR-cover compositor, which only accepts MA-hosted sources.
32 """
33 if hasattr(item, "image") and item.image:
34 return provider.mass.metadata.get_image_url(item.image, prefer_proxy=prefer_proxy)
35 return None
36
37
38async def get_album_image_fallback(album: Any, provider: MSXBridgeProvider) -> str | None:
39 """Get album image from its first track (albums often lack metadata images)."""
40 try:
41 tracks = await provider.mass.music.albums.tracks(album.item_id, album.provider)
42 for track in tracks:
43 if hasattr(track, "image") and track.image:
44 return provider.mass.metadata.get_image_url(track.image)
45 except Exception:
46 logger.debug("Failed to fetch album image fallback for %s", album.item_id)
47 return None
48
49
50async def map_album_to_msx(
51 album: Any, prefix: str, provider: MSXBridgeProvider, device_param: str = ""
52) -> MsxItem:
53 """Map a MA Album to an MSX Item."""
54 image = get_image_url(album, provider)
55 if not image:
56 image = await get_album_image_fallback(album, provider)
57
58 artist = getattr(album, "artist_str", "")
59 year = getattr(album, "year", None)
60 # Build footer: "Artist · 2024" or just one
61 footer: str | None = (
62 f"{artist} · {year}" if artist and year else (artist or (str(year) if year else None))
63 )
64 url = f"{prefix}/msx/albums/{album.item_id}/tracks.json?provider={album.provider}"
65 return MsxItem(
66 title=album.name,
67 title_footer=footer,
68 image=image,
69 action=f"content:{append_device_param(url, device_param)}",
70 )
71
72
73def map_artist_to_msx(
74 artist: Any, prefix: str, provider: MSXBridgeProvider, device_param: str = ""
75) -> MsxItem:
76 """Map a MA Artist to an MSX Item."""
77 url = f"{prefix}/msx/artists/{artist.item_id}/albums.json"
78 return MsxItem(
79 title=artist.name,
80 image=get_image_url(artist, provider),
81 action=f"content:{append_device_param(url, device_param)}",
82 )
83
84
85def map_playlist_to_msx(
86 playlist: Any, prefix: str, provider: MSXBridgeProvider, device_param: str = ""
87) -> MsxItem:
88 """Map a MA Playlist to an MSX Item."""
89 owner = getattr(playlist, "owner", None)
90 prov = getattr(playlist, "provider", None)
91 footer: str | None = f"{owner} · {prov}" if owner and prov else (owner or prov or None)
92 url = f"{prefix}/msx/playlists/{playlist.item_id}/tracks.json"
93 return MsxItem(
94 title=playlist.name,
95 title_footer=footer,
96 image=get_image_url(playlist, provider),
97 action=f"content:{append_device_param(url, device_param)}",
98 )
99
100
101def _build_audio_action(
102 prefix: str,
103 player_id: str,
104 track_uri: str,
105 token: str,
106 device_param: str = "",
107 from_playlist: bool = False,
108) -> str:
109 """Build audio action URL for MSX playback."""
110 # Standard HTTP streaming mode
111 audio_url = f"{prefix}/msx/audio/{player_id}?uri={quote(track_uri, safe='')}&token={token}"
112 if from_playlist:
113 audio_url += "&from_playlist=1"
114 audio_url = append_device_param(audio_url, device_param)
115 return f"audio:{audio_url}"
116
117
118def map_track_to_msx(
119 track: Any,
120 prefix: str,
121 player_id: str,
122 provider: MSXBridgeProvider,
123 device_param: str = "",
124 playlist_url: str | None = None,
125) -> MsxItem:
126 """Map a MA Track to an MSX Item."""
127 duration = getattr(track, "duration", 0) or 0
128 duration_str = f"{duration // 60}:{duration % 60:02d}" if duration else ""
129 artist = getattr(track, "artist_str", "")
130 image_url = get_image_url(track, provider)
131
132 # Build footer: "Artist · 3:42" or just one of them
133 footer: str | None = (
134 f"{artist} · {duration_str}"
135 if artist and duration_str
136 else (artist or duration_str or None)
137 )
138
139 if playlist_url:
140 # playlist: (not auto:) loads the playlist and executes the content
141 # root's action ("player:play") which starts playback AND shows the
142 # player in foreground. playlist:auto: plays in background.
143 # Items are rotated so the desired track is at index 0.
144 action = f"playlist:{playlist_url}"
145 else:
146 action = _build_audio_action(
147 prefix=prefix,
148 player_id=player_id,
149 track_uri=track.uri,
150 token=provider.get_stream_token(player_id),
151 device_param=device_param,
152 )
153
154 return MsxItem(
155 title_header="{txt:msx-white:" + track.name + "}",
156 title_footer=footer,
157 player_label=track.name,
158 duration=duration,
159 image=image_url,
160 background=image_url,
161 action=action,
162 )
163
164
165def map_tracks_to_msx_playlist(
166 tracks: list[Any],
167 start_index: int,
168 prefix: str,
169 player_id: str,
170 provider: MSXBridgeProvider,
171 device_param: str = "",
172 qr_cover_base: str | None = None,
173) -> MsxContent:
174 """
175 Map a list of MA Track objects to an MSX Content page for playlist playback.
176
177 MSX ``playlist:{URL}`` loads a standard Content Root Object.
178 Each item uses ``action: "audio:{URL}"`` so MSX can play them sequentially.
179 The page-level ``action`` auto-starts playback at the requested track index.
180
181 :param qr_cover_base: When set (active party), item backgrounds are routed
182 through this QR-compositing endpoint so the join QR shows on covers.
183 """
184 token = provider.get_stream_token(player_id)
185 msx_items = []
186 for track in tracks:
187 duration = getattr(track, "duration", 0) or 0
188 duration_str = f"{duration // 60}:{duration % 60:02d}" if duration else ""
189 artist = getattr(track, "artist_str", "")
190 label = (
191 f"{artist} · {duration_str}"
192 if artist and duration_str
193 else (artist or duration_str or None)
194 )
195 image_url = get_image_url(track, provider)
196 background = image_url
197 if qr_cover_base:
198 # The compositor only accepts MA-hosted images; a remote CDN cover
199 # would be rejected (400) and vanish. Route it through the MA
200 # imageproxy so the QR-stamped background actually loads.
201 proxied = get_image_url(track, provider, prefer_proxy=True)
202 if proxied:
203 background = f"{qr_cover_base}?image={quote(proxied, safe='')}"
204
205 action = _build_audio_action(
206 prefix=prefix,
207 player_id=player_id,
208 track_uri=track.uri,
209 token=token,
210 device_param=device_param,
211 from_playlist=True,
212 )
213
214 msx_items.append(
215 MsxItem(
216 title=track.name,
217 label=label,
218 player_label=track.name,
219 image=image_url,
220 background=background,
221 duration=duration,
222 action=action,
223 )
224 )
225
226 # Rotate items so the desired track is at index 0.
227 # playlist:auto: always starts from index 0, so rotation ensures
228 # the clicked track plays first. Next/prev wrap naturally.
229 if start_index > 0 and start_index < len(msx_items):
230 msx_items = msx_items[start_index:] + msx_items[:start_index]
231
232 return MsxContent(
233 type="list",
234 template=MsxTemplate(
235 type="control",
236 layout="0,0,12,1",
237 image_filler="default",
238 ),
239 items=msx_items,
240 action="player:play",
241 )
242