/
/
/
1"""Queue: read state and edit / delete queue items."""
2# ruff: noqa: TID252 -- relative imports are the canonical MA-provider pattern.
3
4from __future__ import annotations
5
6from typing import TYPE_CHECKING, Any, cast
7
8from fastmcp import Context, FastMCP
9from fastmcp.exceptions import ToolError
10from mcp.types import ToolAnnotations
11from music_assistant_models.enums import QueueOption, RepeatMode
12from music_assistant_models.errors import InvalidDataError, MusicAssistantError
13
14from music_assistant.controllers.player_queues.helpers import build_queue_item
15
16from ..models import AddToQueueResult, QueueBrief, RemoveFromQueueResult
17from ..tags import Tag
18from ._common import (
19 TIMEOUT_FAST,
20 TIMEOUT_MUTATION,
21 TIMEOUT_QUERY,
22 confirm_or_raise,
23 min_insert_index,
24 queue_item_display_name,
25 resolve_added_queue_item,
26 to_brief_queue,
27)
28
29if TYPE_CHECKING:
30 from music_assistant_models.media_items import PlayableMediaItemType
31
32 from music_assistant.mass import MusicAssistant
33
34# Matches MA's default queue page size (and the ``queue://`` resource cap).
35MAX_QUEUE_ITEMS = 500
36
37
38def _queue_items_window_offset(queue: object | None, queue_option: QueueOption) -> int:
39 """Return the ``items()`` offset for locating newly added rows in long queues."""
40 if queue is None:
41 return 0
42 total = int(getattr(queue, "items", 0) or 0)
43 current_index = int(getattr(queue, "current_index", 0) or 0)
44 if queue_option is QueueOption.ADD:
45 return max(0, total - MAX_QUEUE_ITEMS)
46 if queue_option is QueueOption.REPLACE:
47 return 0
48 return max(0, current_index)
49
50
51def _items_window_offset_for_index(index: int) -> int:
52 """Return the ``items()`` offset that centers a window on ``index``."""
53 return max(0, index - MAX_QUEUE_ITEMS // 2)
54
55
56def _require_queue(mass: MusicAssistant, queue_id: str) -> None:
57 """Raise ``ToolError`` when ``queue_id`` does not resolve to a queue."""
58 if mass.player_queues.get(queue_id) is None:
59 raise ToolError(f"Queue {queue_id!r} not found.")
60
61
62async def _add_to_queue_at_index(
63 mass: MusicAssistant,
64 queue_id: str,
65 uri: str,
66 option: str,
67 index: int,
68) -> AddToQueueResult:
69 """
70 Insert media at an absolute 0-based queue index without interrupting playback.
71
72 :param queue_id: Queue identifier from ``QueueBrief.queue_id``.
73 :param uri: Music Assistant URI of the media to insert.
74 :param option: Original placement option, echoed back in the result.
75 :param index: Absolute 0-based insertion position.
76 """
77 queue = mass.player_queues.get(queue_id)
78 if queue is None:
79 raise ToolError(f"Queue {queue_id!r} not found.")
80 min_insert = min_insert_index(queue)
81 # The queue's own row count â items() pages are capped at MAX_QUEUE_ITEMS,
82 # so len() of a page would wrongly reject valid inserts past that cap.
83 item_count = int(getattr(queue, "items", 0) or 0)
84 if index < min_insert:
85 cur = getattr(queue, "current_index", None)
86 buf = getattr(queue, "index_in_buffer", None)
87 raise ToolError(
88 f"Index {index} is before the next insertable position ({min_insert}). "
89 f"current_index={cur!r}, index_in_buffer={buf!r}. "
90 f"Re-call get_active_queue and use index >= next_insertable_index."
91 )
92 if index > item_count:
93 raise ToolError(
94 f"Index {index} is out of range for queue {queue_id!r} "
95 f"(item_count={item_count}, valid range {min_insert}..{item_count})."
96 )
97 offset = _items_window_offset_for_index(index)
98 before_items = mass.player_queues.items(queue_id, limit=MAX_QUEUE_ITEMS, offset=offset)
99 before_item_ids = frozenset(str(getattr(it, "queue_item_id", "")) for it in before_items)
100 try:
101 media_item = await mass.music.get_item_by_uri(uri)
102 except MusicAssistantError as err:
103 raise ToolError(str(err)) from err
104 try:
105 # `_resolve_media_items` is a private MA method with no public equivalent.
106 # It moved from PlayerQueuesController onto an internal MediaResolver
107 # upstream, so resolve the host object across both MA layouts.
108 resolver: Any = getattr(mass.player_queues, "_media_resolver", mass.player_queues)
109 resolved = await resolver._resolve_media_items(media_item, queue_id=queue_id)
110 except InvalidDataError as err:
111 raise ToolError(str(err)) from err
112 queue_items = [
113 build_queue_item(queue_id, cast("PlayableMediaItemType", x))
114 for x in resolved
115 if x and getattr(x, "available", True)
116 ]
117 if not queue_items:
118 raise ToolError("No playable items found")
119 await mass.player_queues.load(
120 queue_id,
121 queue_items,
122 insert_at_index=index,
123 keep_remaining=True,
124 keep_played=True,
125 shuffle=False,
126 )
127 after_items = mass.player_queues.items(queue_id, limit=MAX_QUEUE_ITEMS, offset=offset)
128 # A container URI (album / playlist) expands to per-track rows, so match on
129 # the resolved track URIs as well as the requested URI.
130 resolved_uris = frozenset(
131 str(getattr(x, "uri", "")) for x in resolved if getattr(x, "uri", None)
132 )
133 added = resolve_added_queue_item(
134 after_items, uris=frozenset({uri}) | resolved_uris, before_item_ids=before_item_ids
135 )
136 if added is None:
137 raise ToolError(
138 f"Added {uri!r} to queue {queue_id!r} at index {index} "
139 "but could not locate the new queue row."
140 )
141 return AddToQueueResult(
142 item_id=str(getattr(added, "queue_item_id", "")),
143 uri=uri,
144 name=queue_item_display_name(added),
145 option=option,
146 index=index,
147 )
148
149
150def build_queue_server( # noqa: PLR0915 -- one sub-server registers all queue tools
151 mass: MusicAssistant,
152 *,
153 require_confirmation: bool = True,
154 delete_queue_enabled: bool = True,
155) -> FastMCP:
156 """Construct the ``queue/*`` sub-server."""
157 sub: FastMCP = FastMCP(name="queue")
158
159 def _queue_brief(queue_id: str, include_items: int) -> QueueBrief:
160 queue = mass.player_queues.get(queue_id)
161 if queue is None:
162 raise ToolError(f"Queue {queue_id!r} not found after move.")
163 limit = min(max(include_items, 0), MAX_QUEUE_ITEMS)
164 items = mass.player_queues.items(queue.queue_id, limit=limit, offset=0) if limit > 0 else []
165 return to_brief_queue(queue, items=list(items))
166
167 @sub.tool(
168 tags={Tag.QUERY_QUEUE},
169 annotations=ToolAnnotations(
170 title="Get active queue",
171 readOnlyHint=True,
172 destructiveHint=False,
173 idempotentHint=True,
174 openWorldHint=False,
175 ),
176 timeout=TIMEOUT_FAST,
177 ) # type: ignore[untyped-decorator, unused-ignore]
178 async def get_active_queue(
179 player_id: str = "",
180 include_items: int = 25,
181 items_from_current: bool = False,
182 queue_id: str = "",
183 ) -> QueueBrief | None:
184 """
185 Return the active queue for a player, or ``None`` if the player is idle.
186
187 Returns ``QueueBrief`` with ``queue_id``, ``current_index``,
188 ``index_in_buffer``, ``next_insertable_index``, ``item_count``,
189 shuffle / repeat flags, ``available`` and up to ``include_items``
190 queue ``items``. Each ``QueueItemBrief`` includes its absolute ``index``.
191
192 Use ``next_insertable_index`` when choosing ``add_to_queue(index=â¦)`` â
193 indices at or before ``index_in_buffer`` are already played or buffered
194 and cannot receive new rows. ``current_index`` is the now-playing row.
195
196 By default ``items`` are fetched from the start of the queue (offset 0),
197 which suits bulk inspect / remove workflows. Set ``items_from_current=True``
198 to fetch a lookahead window from the current playback position instead.
199
200 ``QueueBrief.queue_id`` is the identifier the mutation tools
201 (``set_shuffle``, ``set_repeat``, ``add_to_queue``, ``remove_item``,
202 ``move_item``, ``move_item_to_end``, ``clear_queue``,
203 ``transfer_queue``) expect; for a standard player-backed queue that
204 value equals ``PlayerBrief.player_id``. For a queue fed by an external
205 plugin source (Connect / AirPlay / Ynison), the current item's ``name``
206 is the real track title rather than the source wrapper name.
207
208 :param player_id: Player identifier from ``PlayerBrief.player_id``.
209 :param include_items: How many items to materialise. Clamped to the
210 ``[0, 500]`` range â 500 matches MA's own queue page size and the
211 ``queue://`` resource cap, preventing a hostile or sloppy client from
212 forcing the server to load thousands of rows on every call.
213 :param items_from_current: When ``True``, fetch ``items`` from
214 ``current_index`` rather than the queue start. ``items_start_index``
215 in the response reflects the offset used.
216 :param queue_id: Convenience alias for ``player_id`` when an agent
217 passes the queue identifier instead. Supply one of the two;
218 ignored when ``player_id`` is given.
219 """
220 target = player_id or queue_id
221 if not target:
222 raise ToolError("Provide player_id (from PlayerBrief.player_id) or queue_id.")
223 queue = mass.player_queues.get_active_queue(target)
224 if queue is None:
225 return None
226 limit = min(max(include_items, 0), MAX_QUEUE_ITEMS)
227 current_index = getattr(queue, "current_index", None)
228 items_offset = max(0, int(current_index or 0)) if items_from_current else 0
229 if limit > 0:
230 items = mass.player_queues.items(queue.queue_id, limit=limit, offset=items_offset)
231 else:
232 items = []
233 return to_brief_queue(queue, items=list(items), items_offset=items_offset)
234
235 @sub.tool(
236 tags={Tag.EDIT_QUEUE},
237 annotations=ToolAnnotations(
238 title="Toggle queue shuffle",
239 readOnlyHint=False,
240 destructiveHint=False,
241 idempotentHint=True,
242 openWorldHint=False,
243 ),
244 timeout=TIMEOUT_MUTATION,
245 ) # type: ignore[untyped-decorator, unused-ignore]
246 async def set_shuffle(queue_id: str, enabled: bool) -> None:
247 """
248 Enable or disable shuffle on the given queue.
249
250 Setting the current value again is a no-op. Returns nothing.
251
252 :param queue_id: Queue identifier from ``QueueBrief.queue_id`` (distinct
253 from ``PlayerBrief.player_id``).
254 :param enabled: ``True`` to shuffle, ``False`` to play in queue order.
255 """
256 await mass.player_queues.set_shuffle(queue_id, enabled)
257
258 @sub.tool(
259 tags={Tag.EDIT_QUEUE},
260 annotations=ToolAnnotations(
261 title="Set queue repeat mode",
262 readOnlyHint=False,
263 destructiveHint=False,
264 idempotentHint=True,
265 openWorldHint=False,
266 ),
267 timeout=TIMEOUT_MUTATION,
268 ) # type: ignore[untyped-decorator, unused-ignore]
269 async def set_repeat(queue_id: str, repeat_mode: str = "off") -> None:
270 """
271 Set the repeat mode for the given queue.
272
273 Setting the current value again is a no-op. Returns nothing.
274
275 :param queue_id: Queue identifier from ``QueueBrief.queue_id`` (distinct
276 from ``PlayerBrief.player_id``).
277 :param repeat_mode: Repeat mode:
278
279 - ``off`` (default): No repeating.
280 - ``one``: Repeat the current track.
281 - ``all``: Repeat the entire queue.
282 """
283 # RepeatMode._missing_ silently falls back to UNKNOWN for invalid values
284 # instead of raising ValueError, so we must validate explicitly.
285 mode = RepeatMode(repeat_mode.lower())
286 if mode is RepeatMode.UNKNOWN:
287 valid = ", ".join(f"``{e.value}``" for e in RepeatMode if e is not RepeatMode.UNKNOWN)
288 raise ToolError(f"Invalid repeat_mode {repeat_mode!r}. Valid options: {valid}")
289
290 await mass.player_queues.set_repeat(queue_id, mode)
291
292 @sub.tool(
293 tags={Tag.DELETE_QUEUE},
294 annotations=ToolAnnotations(
295 title="Clear queue",
296 readOnlyHint=False,
297 destructiveHint=True,
298 idempotentHint=True,
299 openWorldHint=False,
300 ),
301 timeout=TIMEOUT_MUTATION,
302 ) # type: ignore[untyped-decorator, unused-ignore]
303 async def clear_queue(queue_id: str, ctx: Context | None = None) -> None:
304 """
305 Clear all items from the given queue. Cannot be undone.
306
307 When ``Confirm destructive operations`` is enabled in the plugin
308 settings the client is asked to confirm before the queue is cleared.
309 Returns nothing.
310
311 :param queue_id: Queue identifier from ``QueueBrief.queue_id``.
312 """
313 await confirm_or_raise(
314 ctx,
315 f"Clear all items from queue {queue_id!r}? This cannot be undone.",
316 enabled=require_confirmation,
317 )
318 mass.player_queues.clear(queue_id)
319
320 @sub.tool(
321 tags={Tag.CONTROL_PLAYBACK},
322 annotations=ToolAnnotations(
323 title="Transfer queue between players",
324 readOnlyHint=False,
325 destructiveHint=True,
326 idempotentHint=False,
327 openWorldHint=False,
328 ),
329 timeout=TIMEOUT_MUTATION,
330 ) # type: ignore[untyped-decorator, unused-ignore]
331 async def transfer_queue(source_queue_id: str, target_queue_id: str) -> None:
332 """
333 Move the contents and playback state of one queue onto another player.
334
335 The source player stops playing and its queue is emptied. Returns
336 nothing.
337
338 :param source_queue_id: Queue identifier of the player currently
339 holding the queue (from ``QueueBrief.queue_id``).
340 :param target_queue_id: Queue identifier of the player that should
341 receive the queue.
342 """
343 await mass.player_queues.transfer_queue(source_queue_id, target_queue_id)
344
345 @sub.tool(
346 tags={Tag.DELETE_QUEUE},
347 annotations=ToolAnnotations(
348 title="Remove items from queue",
349 readOnlyHint=False,
350 destructiveHint=True,
351 idempotentHint=False,
352 openWorldHint=False,
353 ),
354 timeout=TIMEOUT_MUTATION,
355 ) # type: ignore[untyped-decorator, unused-ignore]
356 async def remove_item(
357 queue_id: str,
358 item_ids: list[str],
359 ctx: Context | None = None,
360 ) -> RemoveFromQueueResult:
361 """
362 Remove one or more **up-next** items from a queue by ``item_id``.
363
364 Call ``get_active_queue`` first to list items and their stable
365 ``item_id`` values, then pass all ids in a single call rather than
366 removing one at a time.
367
368 Only rows after the current playback position are deleted. Every
369 requested id is acknowledged in exactly one ``RemoveFromQueueResult``
370 bucket: ``removed`` (verified deleted), ``skipped_played`` (at or
371 before the now-playing row), ``skipped_buffered`` (already loaded in
372 the player's audio buffer), or ``not_found`` (unknown or stale id).
373 A stale id never aborts the batch, so rows deleted earlier in the
374 call are always reported.
375
376 When ``Confirm destructive operations`` is enabled the client is
377 asked to confirm before any item is removed.
378
379 :param queue_id: Queue identifier from ``QueueBrief.queue_id``.
380 :param item_ids: ``item_id`` values from ``QueueItemBrief`` returned
381 by ``get_active_queue``. At least one id is required.
382 """
383 if not item_ids:
384 raise ToolError(
385 "Provide at least one item_id from QueueBrief.items[].item_id "
386 "(use get_active_queue first)."
387 )
388 _require_queue(mass, queue_id)
389 await confirm_or_raise(
390 ctx,
391 f"Remove {len(item_ids)} item(s) from queue {queue_id!r}? This cannot be undone.",
392 enabled=require_confirmation,
393 )
394 queue = mass.player_queues.get(queue_id)
395 current_index = getattr(queue, "current_index", None) if queue else None
396 index_in_buffer = getattr(queue, "index_in_buffer", None) if queue else None
397 result = RemoveFromQueueResult()
398 for item_id in item_ids:
399 item_index = mass.player_queues.index_by_id(queue_id, item_id)
400 if item_index is None:
401 result.not_found.append(item_id)
402 continue
403 # Played first: MA keeps index_in_buffer >= current_index, so the
404 # buffer check would otherwise swallow every history row.
405 if current_index is not None and item_index <= current_index:
406 result.skipped_played.append(item_id)
407 continue
408 if index_in_buffer is not None and item_index <= index_in_buffer:
409 result.skipped_buffered.append(item_id)
410 continue
411 try:
412 mass.player_queues.delete_item(queue_id, item_id)
413 except KeyError, InvalidDataError:
414 # Raced with another client between resolve and delete.
415 result.not_found.append(item_id)
416 continue
417 # MA silently ignores deletes of rows already in the player
418 # buffer, so verify the row is gone before claiming "removed".
419 if mass.player_queues.index_by_id(queue_id, item_id) is None:
420 result.removed.append(item_id)
421 else:
422 result.skipped_buffered.append(item_id)
423 return result
424
425 @sub.tool(
426 tags={Tag.EDIT_QUEUE},
427 annotations=ToolAnnotations(
428 title="Move queue item",
429 readOnlyHint=False,
430 destructiveHint=False,
431 idempotentHint=False,
432 openWorldHint=False,
433 ),
434 timeout=TIMEOUT_MUTATION,
435 ) # type: ignore[untyped-decorator, unused-ignore]
436 async def move_item(
437 queue_id: str, item_id: str, pos_shift: int = 1, include_items: int = 25
438 ) -> QueueBrief:
439 """
440 Move an existing queue row up, down, or to play next.
441
442 Call ``get_active_queue`` first for ``item_id`` values. The currently
443 playing or buffered item cannot be moved. Returns the reordered
444 ``QueueBrief`` so the new order can be confirmed without a separate
445 ``get_active_queue`` call.
446
447 :param queue_id: Queue identifier from ``QueueBrief.queue_id``.
448 :param item_id: ``item_id`` from ``QueueItemBrief`` returned by
449 ``get_active_queue``.
450 :param pos_shift: Relative move â ``-1`` up one slot, ``+1`` down one
451 slot (default), ``0`` to insert at the front of the upcoming items
452 (play next), which while playing is behind ``index_in_buffer``.
453 :param include_items: How many items to materialise in the returned
454 brief. Clamped to the ``[0, 500]`` range.
455 """
456 _require_queue(mass, queue_id)
457 try:
458 mass.player_queues.move_item(queue_id, item_id, pos_shift)
459 except (KeyError, IndexError, InvalidDataError) as exc:
460 raise ToolError(str(exc)) from exc
461 return _queue_brief(queue_id, include_items)
462
463 @sub.tool(
464 tags={Tag.EDIT_QUEUE},
465 annotations=ToolAnnotations(
466 title="Move queue item to end",
467 readOnlyHint=False,
468 destructiveHint=False,
469 idempotentHint=False,
470 openWorldHint=False,
471 ),
472 timeout=TIMEOUT_MUTATION,
473 ) # type: ignore[untyped-decorator, unused-ignore]
474 async def move_item_to_end(queue_id: str, item_id: str, include_items: int = 25) -> QueueBrief:
475 """
476 Move an existing queue row to the back of the queue.
477
478 Call ``get_active_queue`` first for ``item_id`` values. The currently
479 playing or buffered item cannot be moved. Returns the reordered
480 ``QueueBrief`` so the new order can be confirmed without a separate
481 ``get_active_queue`` call.
482
483 :param queue_id: Queue identifier from ``QueueBrief.queue_id``.
484 :param item_id: ``item_id`` from ``QueueItemBrief`` returned by
485 ``get_active_queue``.
486 :param include_items: How many items to materialise in the returned
487 brief. Clamped to the ``[0, 500]`` range.
488 """
489 _require_queue(mass, queue_id)
490 try:
491 mass.player_queues.move_item_end(queue_id, item_id)
492 except (KeyError, IndexError, InvalidDataError) as exc:
493 raise ToolError(str(exc)) from exc
494 return _queue_brief(queue_id, include_items)
495
496 @sub.tool(
497 tags={Tag.EDIT_QUEUE},
498 annotations=ToolAnnotations(
499 title="Add media to queue",
500 readOnlyHint=False,
501 destructiveHint=True,
502 idempotentHint=False,
503 openWorldHint=False,
504 ),
505 timeout=TIMEOUT_QUERY,
506 ) # type: ignore[untyped-decorator, unused-ignore]
507 async def add_to_queue(
508 queue_id: str,
509 uri: str,
510 option: str = "add",
511 index: int | None = None,
512 ) -> AddToQueueResult:
513 """
514 Enqueue media on a queue with an explicit placement mode.
515
516 Supports different enqueue modes to control where items are placed
517 and whether playback is affected. When ``index`` is provided it
518 overrides ``option`` placement and inserts at that absolute 0-based
519 queue position without interrupting playback.
520
521 Call ``get_active_queue(include_items=â¦)`` first to inspect row order,
522 ``next_insertable_index``, and per-item ``index`` when choosing ``index``.
523 For play-next placement only, ``option=next`` is simpler than computing
524 an index. Read ``next_insertable_index`` from ``get_active_queue`` before
525 setting ``index``; do not insert at or before ``index_in_buffer``.
526
527 Returns ``AddToQueueResult`` with the new row's ``item_id``, ``uri``,
528 ``name``, and ``option`` so callers can confirm the add succeeded
529 before enqueueing the next item. When ``index`` was used, ``index``
530 in the result echoes the insertion position.
531
532 :param queue_id: Queue identifier from ``QueueBrief.queue_id`` (distinct
533 from ``PlayerBrief.player_id``).
534 :param uri: Music Assistant URI of the media to add, of the form
535 ``<provider>://<media_type>/<id>`` (e.g. as found on
536 ``TrackBrief.uri`` / ``AlbumBrief.uri`` / ``PlaylistBrief.uri``).
537 :param option: Enqueue mode controlling placement and playback when
538 ``index`` is omitted:
539
540 - ``add`` (default): Append to the end of the queue without
541 interrupting the current item. Preferred for "add to queue"
542 requests â unlike ``playback_play_media``, this keeps what is
543 already playing.
544 - ``next``: Insert after the currently playing item (plays next).
545 - ``play``: Insert after current item and start playing immediately.
546 - ``replace_next``: Replace all items after the current one.
547 - ``replace``: Clear the queue and replace with the new media.
548 :param index: Optional 0-based absolute queue index. When set, overrides
549 ``option`` and inserts without starting playback. Must be at or after
550 the next insertable position (after the current and buffered rows).
551 Valid range is ``min_insert .. item_count`` inclusive.
552 """
553 # QueueOption._missing_ silently falls back to UNKNOWN for invalid values
554 # instead of raising ValueError, so validate explicitly â for the index
555 # path too, where an unvalidated option would otherwise be echoed back.
556 queue_option = QueueOption(option)
557 if queue_option is QueueOption.UNKNOWN:
558 valid = ", ".join(f"``{e.value}``" for e in QueueOption if e is not QueueOption.UNKNOWN)
559 raise ToolError(f"Invalid option {option!r}. Valid options: {valid}")
560
561 if index is not None:
562 if queue_option in {QueueOption.REPLACE, QueueOption.REPLACE_NEXT}:
563 raise ToolError(
564 "``replace`` and ``replace_next`` cannot be combined with ``index``."
565 )
566 return await _add_to_queue_at_index(mass, queue_id, uri, option, index)
567
568 if (
569 queue_option in {QueueOption.REPLACE, QueueOption.REPLACE_NEXT}
570 and not delete_queue_enabled
571 ):
572 raise ToolError(
573 "Option requires delete:queue permission "
574 "(``replace`` and ``replace_next`` clear queue items)."
575 )
576
577 queue = mass.player_queues.get(queue_id)
578 offset = _queue_items_window_offset(queue, queue_option)
579 before_items = mass.player_queues.items(queue_id, limit=MAX_QUEUE_ITEMS, offset=offset)
580 before_item_ids = frozenset(str(getattr(it, "queue_item_id", "")) for it in before_items)
581 await mass.player_queues.play_media(queue_id, uri, option=queue_option)
582 # Re-read the queue after the add: an ``add`` onto a queue longer than
583 # MAX_QUEUE_ITEMS appends rows beyond the pre-add window, so recompute
584 # the offset from the updated total or the new tail is missed.
585 updated = mass.player_queues.get(queue_id)
586 after_offset = _queue_items_window_offset(updated, queue_option)
587 after_items = mass.player_queues.items(queue_id, limit=MAX_QUEUE_ITEMS, offset=after_offset)
588 added = resolve_added_queue_item(
589 after_items, uris=frozenset({uri}), before_item_ids=before_item_ids
590 )
591 if added is None:
592 raise ToolError(
593 f"Added {uri!r} to queue {queue_id!r} but could not locate the new queue row."
594 )
595 return AddToQueueResult(
596 item_id=str(getattr(added, "queue_item_id", "")),
597 uri=uri,
598 name=queue_item_display_name(added),
599 option=option,
600 )
601
602 return sub
603