music-assistant-server

25.6 KBPY
queue.py
25.6 KB603 lines • python
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