/
/
1"""MPD Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any, cast
8
9from mpd import CommandError, FailureResponseCode, MPDError
10from mpd.asyncio import MPDClient
11from music_assistant_models.config_entries import ConfigEntry
12from music_assistant_models.enums import (
13 ConfigEntryType,
14 IdentifierType,
15 PlaybackState,
16 PlayerFeature,
17)
18from music_assistant_models.errors import PlayerCommandFailed
19from music_assistant_models.player import DeviceInfo, PlayerMedia
20
21from music_assistant.constants import (
22 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES_DEFAULT_ENABLED,
23 CONF_PASSWORD,
24)
25from music_assistant.models.player import Player
26from music_assistant.models.setup_flow import SetupFlowError
27
28from .constants import (
29 CONF_ENTRY_OUTPUT_CODEC_MPD,
30 ELAPSED_POLL_INTERVAL,
31 MPD_STATE_MAP,
32 RECONNECT_DELAY,
33)
34
35if TYPE_CHECKING:
36 from music_assistant.models.setup_flow import SetupSession
37
38 from .provider import MPDPlayerProvider
39
40
41class MPDPlayer(Player):
42 """Represents a single MPD server as a Music Assistant player."""
43
44 _attr_poll_interval = ELAPSED_POLL_INTERVAL
45
46 def __init__(
47 self,
48 provider: MPDPlayerProvider,
49 player_id: str,
50 host: str,
51 port: int = 6600,
52 ) -> None:
53 """
54 Initialize MPDPlayer.
55
56 :param provider: The MPDPlayerProvider instance.
57 :param player_id: Unique player identifier.
58 :param host: Hostname or IP address of the MPD server.
59 :param port: TCP port MPD is listening on.
60 """
61 super().__init__(provider, player_id)
62 self.host = host
63 self.port = port
64
65 # Two separate MPD connections are required:
66 # - _client: for sending commands (play, stop, setvol, etc.)
67 # - _idle_client: dedicated to the blocking idle() loop
68 # They must be separate because idle() monopolises the connection
69 # until a subsystem changes, preventing any other commands from being sent.
70 self._client: MPDClient | None = None
71 self._idle_client: MPDClient | None = None
72 self._idle_task: asyncio.Task[None] | None = None
73 self._attr_needs_setup: bool = False
74 self._attr_setup_reason: str | None = None
75
76 self._attr_name = f"MPD ({host})"
77 self._attr_supported_features = {
78 PlayerFeature.PLAY_MEDIA,
79 PlayerFeature.PAUSE,
80 PlayerFeature.SEEK,
81 PlayerFeature.VOLUME_SET,
82 }
83
84 def _schedule_disconnect() -> None:
85 self.mass.call_later(0, self._disconnect, task_id=f"mpd_disconnect_{self.player_id}")
86
87 self._on_unload_callbacks.append(_schedule_disconnect)
88
89 @property
90 def needs_setup(self) -> bool:
91 """
92 Return True if the player requires a password to be configured.
93
94 :return: True when a password is required but has not yet been provided.
95 """
96 return self._attr_needs_setup
97
98 @property
99 def needs_poll(self) -> bool:
100 """
101 Return True when elapsed time polling is required.
102
103 MPD's idle mechanism does not push elapsed time continuously;
104 polling fills this gap during active playback.
105
106 :return: True when playback is active.
107 """
108 return self._attr_playback_state == PlaybackState.PLAYING
109
110 async def get_config_entries(self) -> list[ConfigEntry]:
111 """
112 Return player-level config entries.
113
114 :return: List of ConfigEntry objects for this player.
115 """
116 return [
117 CONF_ENTRY_OUTPUT_CODEC_MPD,
118 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES_DEFAULT_ENABLED,
119 ]
120
121 async def on_config_updated(self) -> None:
122 """Reconnect to MPD when player configuration changes."""
123 self.password = cast("str | None", self.get_setup_value(CONF_PASSWORD) or None)
124 self._attr_needs_setup = False
125 self._attr_setup_reason = None
126 await self._disconnect()
127 await self._connect()
128
129 async def run_setup_flow(self, session: SetupSession) -> None:
130 """Run the setup flow: collect the MPD server password."""
131 entries = [
132 ConfigEntry(key=CONF_PASSWORD, type=ConfigEntryType.SECURE_STRING, required=True)
133 ]
134 errors: dict[str, str] | None = None
135 while True:
136 values = await session.form(entries, step_id="user", errors=errors, last_step=True)
137 try:
138 await session.finish({CONF_PASSWORD: str(values[CONF_PASSWORD])})
139 return
140 except SetupFlowError as err:
141 errors = {"base": err.translation_key or str(err)}
142
143 async def poll(self) -> None:
144 """Fetch current MPD state to update elapsed time."""
145 await self._fetch_and_sync_state()
146
147 # ------------------------------------------------------------------
148 # Connection helpers
149 # ------------------------------------------------------------------
150
151 async def _connect(self) -> None:
152 """Establish both MPD connections and start the idle loop."""
153 try:
154 self._client = MPDClient()
155 await self._client.connect(self.host, self.port)
156 if self.password:
157 await self._client.password(self.password)
158
159 self._idle_client = MPDClient()
160 await self._idle_client.connect(self.host, self.port)
161 if self.password:
162 await self._idle_client.password(self.password)
163
164 status = await self._client.status()
165 self._attr_available = True
166 self._attr_needs_setup = False
167 self._attr_setup_reason = None
168 self._attr_device_info = DeviceInfo(
169 model=f"MPD {self._client.mpd_version}",
170 manufacturer="Music Player Daemon",
171 )
172 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.host)
173 self.logger.info("Connected to MPD at %s:%s", self.host, self.port)
174 self._idle_task = self.mass.create_task(self._idle_loop())
175 await self._sync_state(status)
176 self.update_state()
177
178 except CommandError as err:
179 if err.errno in (FailureResponseCode.PASSWORD, FailureResponseCode.PERMISSION):
180 self.logger.warning(
181 "Authentication failed for MPD at %s:%s â configure password in player settings",
182 self.host,
183 self.port,
184 )
185 self._attr_available = False
186 self._attr_needs_setup = True
187 self._attr_setup_reason = "password_required"
188 self.update_state()
189 else:
190 self.logger.warning("MPD command error at %s:%s: %s", self.host, self.port, err)
191 self._attr_available = False
192 self.update_state()
193 self.reconnect()
194 except (MPDError, OSError) as err:
195 self.logger.warning("Failed to connect to MPD at %s:%s: %s", self.host, self.port, err)
196 self._attr_available = False
197 self.update_state()
198 self.reconnect()
199
200 async def _disconnect(self) -> None:
201 """Disconnect both MPD clients and cancel the idle loop task."""
202 if self._idle_task:
203 self._idle_task.cancel()
204 self._idle_task = None
205 for client in (self._client, self._idle_client):
206 if client:
207 client.disconnect()
208 self._client = None
209 self._idle_client = None
210
211 def reconnect(self) -> None:
212 """Schedule a reconnect attempt, deduplicating any pending reconnect tasks."""
213 task_id = f"mpd_reconnect_{self.player_id}"
214 self.mass.call_later(RECONNECT_DELAY, self._connect, task_id=task_id)
215
216 # ------------------------------------------------------------------
217 # Background idle loop
218 # ------------------------------------------------------------------
219
220 async def _idle_loop(self) -> None:
221 """Receive push state changes from MPD and sync to MA."""
222 if self._idle_client is None:
223 return
224 try:
225 # MPD's idle command blocks until a subsystem changes, then yields
226 # the subsystem name. We act on player/mixer/playlist changes.
227 async for subsystem in self._idle_client.idle():
228 if subsystem in ("player", "mixer", "playlist"):
229 await self._fetch_and_sync_state()
230 except MPDError as err:
231 self.logger.warning("MPD idle loop disconnected: %s", err)
232 self._attr_available = False
233 self.update_state()
234 self.reconnect()
235
236 # ------------------------------------------------------------------
237 # State sync
238 # ------------------------------------------------------------------
239
240 async def _fetch_and_sync_state(self) -> None:
241 """Fetch current MPD status and update MA player state."""
242 if self._client is None:
243 return
244 try:
245 status = await self._client.status()
246 await self._sync_state(status)
247 self.update_state()
248 except MPDError as err:
249 self.logger.warning("Failed to fetch MPD status: %s", err)
250
251 async def _sync_state(self, status: dict[str, Any]) -> None:
252 """
253 Map an MPD status dict onto MA player attributes.
254
255 :param status: Status dict as returned by the MPD ``status`` command.
256 """
257 mpd_state = status.get("state", "stop")
258 self._attr_playback_state = MPD_STATE_MAP.get(mpd_state, PlaybackState.IDLE)
259
260 # Volume: MPD reports -1 when no mixer is available
261 volume_str = status.get("volume", "-1")
262 if volume_str != "-1":
263 self._attr_volume_level = max(0, min(100, int(volume_str)))
264
265 # Elapsed time: MPD reports as a float string, absent when stopped
266 elapsed_str = status.get("elapsed")
267 if elapsed_str is not None:
268 self._attr_elapsed_time = float(elapsed_str)
269 self._attr_elapsed_time_last_updated = time.time()
270 else:
271 self._attr_elapsed_time = 0
272
273 # ------------------------------------------------------------------
274 # Player commands
275 # ------------------------------------------------------------------
276
277 async def play_media(self, media: PlayerMedia) -> None:
278 """
279 Send a play command to MPD using the MA stream URL.
280
281 :param media: Details of the media item to play.
282 """
283 if self._client is None:
284 return
285 url = await self.mass.streams.resolve_stream_url(self.player_id, media)
286 self.logger.debug("Sending stream URL to MPD: %s", url)
287 try:
288 await self._client.clear()
289 await self._client.add(url)
290 await self._client.play(0)
291 self._attr_current_media = media
292 self._attr_playback_state = PlaybackState.PLAYING
293 self.update_state()
294 except MPDError as err:
295 raise PlayerCommandFailed(f"play_media failed: {err}") from err
296
297 async def play(self) -> None:
298 """Resume playback."""
299 if self._client is None:
300 return
301 try:
302 await self._client.pause(0)
303 self._attr_playback_state = PlaybackState.PLAYING
304 self.update_state()
305 except MPDError as err:
306 raise PlayerCommandFailed(f"play failed: {err}") from err
307
308 async def stop(self) -> None:
309 """Stop playback and clear current media."""
310 if self._client is None:
311 return
312 try:
313 await self._client.stop()
314 self._attr_playback_state = PlaybackState.IDLE
315 self._attr_current_media = None
316 self.update_state()
317 except MPDError as err:
318 raise PlayerCommandFailed(f"stop failed: {err}") from err
319
320 async def pause(self) -> None:
321 """Pause playback."""
322 if self._client is None:
323 return
324 try:
325 await self._client.pause(1)
326 self._attr_playback_state = PlaybackState.PAUSED
327 self.update_state()
328 except MPDError as err:
329 raise PlayerCommandFailed(f"pause failed: {err}") from err
330
331 async def volume_set(self, volume_level: int) -> None:
332 """
333 Set the player volume.
334
335 :param volume_level: Volume level (0-100).
336 """
337 if self._client is None:
338 return
339 try:
340 await self._client.setvol(volume_level)
341 self._attr_volume_level = volume_level
342 self.update_state()
343 except MPDError as err:
344 raise PlayerCommandFailed(f"volume_set failed: {err}") from err
345
346 async def seek(self, position: int) -> None:
347 """
348 Seek to a position in the current track.
349
350 :param position: Position in seconds.
351 """
352 if self._client is None:
353 return
354 try:
355 await self._client.seekcur(position)
356 except MPDError as err:
357 raise PlayerCommandFailed(f"seek failed: {err}") from err
358