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