/
/
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 device_param: str = "",
106 from_playlist: bool = False,
107) -> str:
108 """Build audio action URL for MSX playback."""
109 # Standard HTTP streaming mode
110 audio_url = f"{prefix}/msx/audio/{player_id}?uri={quote(track_uri, safe='')}"
111 if from_playlist:
112 audio_url += "&from_playlist=1"
113 audio_url = append_device_param(audio_url, device_param)
114 return f"audio:{audio_url}"
115
116
117def map_track_to_msx(
118 track: Any,
119 prefix: str,
120 player_id: str,
121 provider: MSXBridgeProvider,
122 device_param: str = "",
123 playlist_url: str | None = None,
124) -> MsxItem:
125 """Map a MA Track to an MSX Item."""
126 duration = getattr(track, "duration", 0) or 0
127 duration_str = f"{duration // 60}:{duration % 60:02d}" if duration else ""
128 artist = getattr(track, "artist_str", "")
129 image_url = get_image_url(track, provider)
130
131 # Build footer: "Artist · 3:42" or just one of them
132 footer: str | None = (
133 f"{artist} · {duration_str}"
134 if artist and duration_str
135 else (artist or duration_str or None)
136 )
137
138 if playlist_url:
139 # playlist: (not auto:) loads the playlist and executes the content
140 # root's action ("player:play") which starts playback AND shows the
141 # player in foreground. playlist:auto: plays in background.
142 # Items are rotated so the desired track is at index 0.
143 action = f"playlist:{playlist_url}"
144 else:
145 action = _build_audio_action(
146 prefix=prefix,
147 player_id=player_id,
148 track_uri=track.uri,
149 device_param=device_param,
150 )
151
152 return MsxItem(
153 title_header="{txt:msx-white:" + track.name + "}",
154 title_footer=footer,
155 player_label=track.name,
156 duration=duration,
157 image=image_url,
158 background=image_url,
159 action=action,
160 )
161
162
163def map_tracks_to_msx_playlist(
164 tracks: list[Any],
165 start_index: int,
166 prefix: str,
167 player_id: str,
168 provider: MSXBridgeProvider,
169 device_param: str = "",
170 qr_cover_base: str | None = None,
171) -> MsxContent:
172 """
173 Map a list of MA Track objects to an MSX Content page for playlist playback.
174
175 MSX ``playlist:{URL}`` loads a standard Content Root Object.
176 Each item uses ``action: "audio:{URL}"`` so MSX can play them sequentially.
177 The page-level ``action`` auto-starts playback at the requested track index.
178
179 :param qr_cover_base: When set (active party), item backgrounds are routed
180 through this QR-compositing endpoint so the join QR shows on covers.
181 """
182 msx_items = []
183 for track in tracks:
184 duration = getattr(track, "duration", 0) or 0
185 duration_str = f"{duration // 60}:{duration % 60:02d}" if duration else ""
186 artist = getattr(track, "artist_str", "")
187 label = (
188 f"{artist} · {duration_str}"
189 if artist and duration_str
190 else (artist or duration_str or None)
191 )
192 image_url = get_image_url(track, provider)
193 background = image_url
194 if qr_cover_base:
195 # The compositor only accepts MA-hosted images; a remote CDN cover
196 # would be rejected (400) and vanish. Route it through the MA
197 # imageproxy so the QR-stamped background actually loads.
198 proxied = get_image_url(track, provider, prefer_proxy=True)
199 if proxied:
200 background = f"{qr_cover_base}?image={quote(proxied, safe='')}"
201
202 action = _build_audio_action(
203 prefix=prefix,
204 player_id=player_id,
205 track_uri=track.uri,
206 device_param=device_param,
207 from_playlist=True,
208 )
209
210 msx_items.append(
211 MsxItem(
212 title=track.name,
213 label=label,
214 player_label=track.name,
215 image=image_url,
216 background=background,
217 duration=duration,
218 action=action,
219 )
220 )
221
222 # Rotate items so the desired track is at index 0.
223 # playlist:auto: always starts from index 0, so rotation ensures
224 # the clicked track plays first. Next/prev wrap naturally.
225 if start_index > 0 and start_index < len(msx_items):
226 msx_items = msx_items[start_index:] + msx_items[:start_index]
227
228 return MsxContent(
229 type="list",
230 template=MsxTemplate(
231 type="control",
232 layout="0,0,12,1",
233 image_filler="default",
234 ),
235 items=msx_items,
236 action="player:play",
237 )
238