/
/
/
1"""Playback control command handlers for Plex remote control."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from typing import TYPE_CHECKING
8
9from aiohttp import web
10from music_assistant_models.enums import RepeatMode
11
12from .parsing import plex_key_for_item
13
14if TYPE_CHECKING:
15 from music_assistant.providers.plex import PlexProvider
16
17LOGGER = logging.getLogger(__name__)
18
19
20class PlaybackMixin:
21 """Mixin providing playback control command handlers."""
22
23 if TYPE_CHECKING:
24 provider: PlexProvider
25 _ma_player_id: str | None
26 _updating_from_plex: bool
27 play_queue_id: str | None
28 play_queue_item_ids: dict[int, int]
29 _last_synced_ma_queue_length: int
30 _last_synced_ma_queue_keys: list[str]
31
32 async def _broadcast_timeline(self) -> None: ...
33
34 async def _create_plex_playqueue_from_ma(self) -> None: ...
35
36 def _remember_synced_queue(
37 self, player_id: str, keys: list[str] | None = None
38 ) -> list[str]: ...
39
40 async def _seek_to_offset_after_playback(self, player_id: str, offset: int) -> None:
41 """
42 Seek to the specified offset after playback starts.
43
44 :param player_id: The player ID to seek on.
45 :param offset: The offset in milliseconds.
46 """
47 queue = self.provider.mass.player_queues.get(player_id)
48 if queue and queue.current_item:
49 try:
50 await self.provider.mass.players.cmd_seek(player_id, offset // 1000)
51 except Exception as e:
52 LOGGER.debug(f"Could not seek to offset {offset}ms: {e}")
53 else:
54 LOGGER.warning("Queue not ready for seeking after timeout")
55
56 async def handle_pause(self, request: web.Request) -> web.Response:
57 """Handle pause command."""
58 self._updating_from_plex = True
59 try:
60 if self._ma_player_id:
61 await self.provider.mass.players.cmd_pause(self._ma_player_id)
62 await self._broadcast_timeline()
63 return web.Response(status=200)
64 finally:
65 self._updating_from_plex = False
66
67 async def handle_play(self, request: web.Request) -> web.Response:
68 """Handle play/resume command."""
69 self._updating_from_plex = True
70 try:
71 if self._ma_player_id:
72 await self.provider.mass.players.cmd_play(self._ma_player_id)
73 await self._broadcast_timeline()
74 return web.Response(status=200)
75 finally:
76 self._updating_from_plex = False
77
78 async def handle_stop(self, request: web.Request) -> web.Response:
79 """Handle stop command - stops playback and clears the queue."""
80 self._updating_from_plex = True
81 try:
82 if self._ma_player_id:
83 self.provider.mass.player_queues.clear(self._ma_player_id)
84 self.play_queue_id = None
85 self.play_queue_item_ids = {}
86 await self._broadcast_timeline()
87 return web.Response(status=200)
88 finally:
89 self._updating_from_plex = False
90
91 async def handle_skip_next(self, request: web.Request) -> web.Response:
92 """Handle skip next command."""
93 self._updating_from_plex = True
94 try:
95 if self._ma_player_id:
96 await self.provider.mass.player_queues.next(self._ma_player_id)
97 await self._broadcast_timeline()
98 return web.Response(status=200)
99 finally:
100 self._updating_from_plex = False
101
102 async def handle_skip_previous(self, request: web.Request) -> web.Response:
103 """Handle skip previous command."""
104 self._updating_from_plex = True
105 try:
106 if self._ma_player_id:
107 await self.provider.mass.player_queues.previous(self._ma_player_id)
108 await self._broadcast_timeline()
109 return web.Response(status=200)
110 finally:
111 self._updating_from_plex = False
112
113 async def handle_step_forward(self, request: web.Request) -> web.Response:
114 """Handle step forward command (small skip forward, 30 seconds)."""
115 self._updating_from_plex = True
116 try:
117 if self._ma_player_id:
118 queue = self.provider.mass.player_queues.get(self._ma_player_id)
119 if queue:
120 new_position = queue.corrected_elapsed_time + 30
121 if queue.current_item and queue.current_item.media_item:
122 max_duration = queue.current_item.media_item.duration or new_position
123 new_position = min(new_position, max_duration)
124 await self.provider.mass.players.cmd_seek(self._ma_player_id, int(new_position))
125 await asyncio.sleep(0.1)
126 await self._broadcast_timeline()
127 return web.Response(status=200)
128 finally:
129 self._updating_from_plex = False
130
131 async def handle_step_back(self, request: web.Request) -> web.Response:
132 """Handle step back command (small skip backward, 10 seconds)."""
133 self._updating_from_plex = True
134 try:
135 if self._ma_player_id:
136 queue = self.provider.mass.player_queues.get(self._ma_player_id)
137 if queue:
138 new_position = max(0, queue.corrected_elapsed_time - 10)
139 await self.provider.mass.players.cmd_seek(self._ma_player_id, int(new_position))
140 await asyncio.sleep(0.1)
141 await self._broadcast_timeline()
142 return web.Response(status=200)
143 finally:
144 self._updating_from_plex = False
145
146 async def handle_seek_to(self, request: web.Request) -> web.Response:
147 """Handle seek command."""
148 self._updating_from_plex = True
149 try:
150 offset_ms = int(request.query.get("offset", 0))
151 if self._ma_player_id:
152 await self.provider.mass.players.cmd_seek(self._ma_player_id, int(offset_ms / 1000))
153 await asyncio.sleep(0.1)
154 await self._broadcast_timeline()
155 return web.Response(status=200)
156 finally:
157 self._updating_from_plex = False
158
159 async def handle_skip_to(self, request: web.Request) -> web.Response:
160 """Handle skip to specific queue item."""
161 key = request.query.get("key")
162 if not self._ma_player_id or not key:
163 return web.Response(status=400, text="Missing player ID or key")
164
165 self._updating_from_plex = True
166 try:
167 ma_index = None
168
169 if key.isdigit():
170 # Key is a play queue item ID â look up MA queue index
171 play_queue_item_id = int(key)
172 for idx, pq_item_id in self.play_queue_item_ids.items():
173 if pq_item_id == play_queue_item_id:
174 ma_index = idx
175 break
176
177 if ma_index is None:
178 LOGGER.warning(
179 f"Could not find MA queue index for play queue item ID: "
180 f"{play_queue_item_id}"
181 )
182 return web.Response(status=404, text="Queue item not found")
183
184 LOGGER.info(
185 f"Skipping to queue index {ma_index} (play queue item ID: {play_queue_item_id})"
186 )
187 else:
188 # Key is a library path â find track in MA queue by Plex key
189 queue_items = self.provider.mass.player_queues.items(self._ma_player_id)
190 if not queue_items:
191 return web.Response(status=404, text="Queue is empty")
192
193 for idx, item in enumerate(queue_items):
194 if plex_key_for_item(item.media_item, self.provider.instance_id) == key:
195 ma_index = idx
196 break
197
198 if ma_index is None:
199 LOGGER.warning(f"Could not find track with key {key} in MA queue")
200 return web.Response(status=404, text="Track not found in queue")
201
202 LOGGER.info(f"Skipping to queue index {ma_index} (track key: {key})")
203
204 await self.provider.mass.player_queues.play_index(self._ma_player_id, ma_index)
205 await self._broadcast_timeline()
206 return web.Response(status=200)
207
208 except Exception:
209 LOGGER.exception("Error handling skipTo")
210 return web.Response(status=500, text="Internal error")
211 finally:
212 self._updating_from_plex = False
213
214 async def handle_set_parameters(self, request: web.Request) -> web.Response:
215 """Handle parameter changes (volume, shuffle, repeat)."""
216 if not self._ma_player_id:
217 return web.Response(status=200)
218
219 self._updating_from_plex = True
220 shuffle_changed = False
221 try:
222 if "volume" in request.query:
223 volume = int(request.query["volume"])
224 # Use group_volume so a group/syncgroup adjusts its members relatively
225 # (matching the group-aware volume reported in the timeline). For a solo
226 # player this falls back to a normal volume set.
227 await self.provider.mass.players.cmd_group_volume(self._ma_player_id, volume)
228
229 if "shuffle" in request.query:
230 shuffle = request.query["shuffle"] == "1"
231 await self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle)
232 shuffle_changed = True
233
234 if "repeat" in request.query:
235 repeat_value = int(request.query["repeat"])
236 if repeat_value == 0:
237 await self.provider.mass.player_queues.set_repeat(
238 self._ma_player_id, RepeatMode.OFF
239 )
240 elif repeat_value == 1:
241 await self.provider.mass.player_queues.set_repeat(
242 self._ma_player_id, RepeatMode.ONE
243 )
244 elif repeat_value == 2:
245 await self.provider.mass.player_queues.set_repeat(
246 self._ma_player_id, RepeatMode.ALL
247 )
248
249 await self._broadcast_timeline()
250 finally:
251 self._updating_from_plex = False
252
253 if shuffle_changed:
254 # MA shuffles the queue asynchronously; give it a moment then sync the
255 # new order back to Plex so Plexamp sees the updated play queue.
256 await asyncio.sleep(0.2)
257 await self._create_plex_playqueue_from_ma()
258 self._remember_synced_queue(self._ma_player_id)
259
260 return web.Response(status=200)
261