diff --git a/.gitignore b/.gitignore index 5e8f6be6..c7bf8404 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,7 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# Test +test.py \ No newline at end of file diff --git a/wavelink/player.py b/wavelink/player.py index 3c602425..39d04aad 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -83,6 +83,13 @@ class Player(discord.VoiceProtocol): Since the Player is a :class:`discord.VoiceProtocol`, it is attached to the various ``voice_client`` attributes in discord.py, including ``guild.voice_client``, ``ctx.voice_client`` and ``interaction.voice_client``. + + Attributes + ---------- + queue: :class:`~wavelink.Queue` + The queue associated with this player. + auto_queue: :class:`~wavelink.Queue` + The auto_queue associated with this player. This queue holds tracks that are recommended by the AutoPlay feature. """ channel: VocalGuildChannel @@ -369,7 +376,7 @@ async def _search(query: str | None) -> T_a: track._recommended = True added += await self.auto_queue.put_wait(track) - random.shuffle(self.auto_queue._queue) + random.shuffle(self.auto_queue._items) logger.debug(f'Player "{self.guild.id}" added "{added}" tracks to the auto_queue via AutoPlay.') # Probably don't need this here as it's likely to be cancelled instantly... @@ -943,7 +950,7 @@ async def stop(self, *, force: bool = True) -> Playable | None: .. versionchanged:: 3.0.0 - This method is now known as ``skip``, but the alias ``stop`` has been kept for backwards compatability. + This method is now known as ``skip``, but the alias ``stop`` has been kept for backwards compatibility. """ return await self.skip(force=force) diff --git a/wavelink/queue.py b/wavelink/queue.py index 3c04307f..c3f43c61 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -26,8 +26,8 @@ import asyncio import random from collections import deque -from collections.abc import Iterator -from typing import overload +from collections.abc import Iterable, Iterator +from typing import SupportsIndex, TypeGuard, overload from .enums import QueueMode from .exceptions import QueueEmpty @@ -36,90 +36,13 @@ __all__ = ("Queue",) -class _Queue: - def __init__(self) -> None: - self._queue: deque[Playable] = deque() - - def __str__(self) -> str: - return ", ".join([f'"{p}"' for p in self]) - - def __repr__(self) -> str: - return f"BaseQueue(items={len(self._queue)})" - - def __bool__(self) -> bool: - return bool(self._queue) - - def __call__(self, item: Playable | Playlist) -> None: - self.put(item) - - def __len__(self) -> int: - return len(self._queue) - - @overload - def __getitem__(self, index: int) -> Playable: - ... - - @overload - def __getitem__(self, index: slice) -> list[Playable]: - ... - - def __getitem__(self, index: int | slice) -> Playable | list[Playable]: - if isinstance(index, slice): - return list(self._queue)[index] - - return self._queue[index] - - def __iter__(self) -> Iterator[Playable]: - return self._queue.__iter__() - - def __contains__(self, item: object) -> bool: - return item in self._queue - - @staticmethod - def _check_compatability(item: object) -> None: - if not isinstance(item, Playable): - raise TypeError("This queue is restricted to Playable objects.") - - def _get(self) -> Playable: - if not self: - raise QueueEmpty("There are no items currently in this queue.") - - return self._queue.popleft() - - def get(self) -> Playable: - return self._get() - - def _check_atomic(self, item: list[Playable] | Playlist) -> None: - for track in item: - self._check_compatability(track) - - def _put(self, item: Playable) -> None: - self._check_compatability(item) - self._queue.append(item) - - def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: - added: int = 0 - - if isinstance(item, Playlist): - if atomic: - self._check_atomic(item) - - for track in item: - try: - self._put(track) - added += 1 - except TypeError: - pass - - else: - self._put(item) - added += 1 - - return added +class Queue: + """The default custom wavelink Queue designed specifically for :class:`wavelink.Player`. + .. note:: -class Queue(_Queue): - """The default custom wavelink Queue designed specifically for :class:`wavelink.Player`. + :class:`~wavelink.Player` implements this queue by default. + You can access it via :attr:`wavelink.Player.queue`. .. container:: operations @@ -155,37 +78,122 @@ class Queue(_Queue): Check whether a specific track is in the queue. + .. describe:: queue[1] = track + + Set a track in the queue at a specific index. + + .. describe:: del queue[1] + + Delete a track from the queue at a specific index. + + .. describe:: reversed(queue) + + Return a reversed iterator of the queue. Attributes ---------- history: :class:`wavelink.Queue` - A queue of tracks that have been added to history. - - Even though the history queue is the same class as this Queue some differences apply. - Mainly you can not set the ``mode``. + A queue of tracks that have been added to history. Tracks are added to history when they are played. """ - def __init__(self, history: bool = True) -> None: - super().__init__() - self.history: Queue | None = None - - if history: - self.history = Queue(history=False) + def __init__(self, *, history: bool = True) -> None: + self._items: list[Playable] = [] - self._loaded: Playable | None = None + self._history: Queue | None = Queue(history=False) if history else None self._mode: QueueMode = QueueMode.normal + self._loaded: Playable | None = None + self._waiters: deque[asyncio.Future[None]] = deque() - self._finished: asyncio.Event = asyncio.Event() - self._finished.set() + self._lock = asyncio.Lock() + + @property + def mode(self) -> QueueMode: + """Property which returns a :class:`~wavelink.QueueMode` indicating which mode the + :class:`~wavelink.Queue` is in. + + This property can be set with any :class:`~wavelink.QueueMode`. - self._lock: asyncio.Lock = asyncio.Lock() + + .. versionadded:: 3.0.0 + """ + return self._mode + + @mode.setter + def mode(self, value: QueueMode) -> None: + self._mode = value + + @property + def history(self) -> Queue | None: + return self._history + + @property + def count(self) -> int: + """The queue member count. + + Returns + ------- + int + The amount of tracks in the queue. + """ + + return len(self) + + @property + def is_empty(self) -> bool: + """Whether the queue has no members. + + Returns + ------- + bool + Whether the queue is empty. + """ + + return not bool(self) def __str__(self) -> str: - return ", ".join([f'"{p}"' for p in self]) + joined: str = ", ".join([f'"{p}"' for p in self]) + return f"Queue([{joined}])" def __repr__(self) -> str: return f"Queue(items={len(self)}, history={self.history!r})" + def __call__(self, item: Playable) -> None: + self.put(item) + + def __bool__(self) -> bool: + return bool(self._items) + + @overload + def __getitem__(self, __index: SupportsIndex, /) -> Playable: + ... + + @overload + def __getitem__(self, __index: slice, /) -> list[Playable]: + ... + + def __getitem__(self, __index: SupportsIndex | slice, /) -> Playable | list[Playable]: + return self._items[__index] + + def __setitem__(self, __index: SupportsIndex, __value: Playable, /) -> None: + self._check_compatibility(__value) + self._items[__index] = __value + self._wakeup_next() + + def __delitem__(self, __index: int | slice, /) -> None: + del self._items[__index] + + def __contains__(self, __other: Playable) -> bool: + return __other in self._items + + def __len__(self) -> int: + return len(self._items) + + def __reversed__(self) -> Iterator[Playable]: + return reversed(self._items) + + def __iter__(self) -> Iterator[Playable]: + return iter(self._items) + def _wakeup_next(self) -> None: while self._waiters: waiter = self._waiters.popleft() @@ -194,38 +202,124 @@ def _wakeup_next(self) -> None: waiter.set_result(None) break - def _get(self) -> Playable: + @staticmethod + def _check_compatibility(item: object) -> TypeGuard[Playable]: + if not isinstance(item, Playable): + raise TypeError("This queue is restricted to Playable objects.") + return True + + @classmethod + def _check_atomic(cls, item: Iterable[object]) -> TypeGuard[Iterable[Playable]]: + for track in item: + cls._check_compatibility(track) + return True + + def get(self) -> Playable: + """Retrieve a track from the left side of the queue. E.g. the first. + + This method does not block. + + .. warning:: + + Due to the way the queue loop works, this method will return the same track if the queue is in loop mode. + You can use :meth:`wavelink.Player.skip` with ``force=True`` to skip the current track. + + Do **NOT** use this method to remove tracks from the queue, use either: + + - ``del queue[index]`` + - :meth:`wavelink.Queue.remove` + - :meth:`wavelink.Queue.delete` + + + Returns + ------- + :class:`wavelink.Playable` + The track retrieved from the queue. + + Raises + ------ + QueueEmpty + The queue was empty when retrieving a track. + """ + if self.mode is QueueMode.loop and self._loaded: return self._loaded if self.mode is QueueMode.loop_all and not self: assert self.history is not None - self._queue.extend(self.history._queue) + self._items.extend(self.history._items) self.history.clear() - track: Playable = super()._get() + if not self: + raise QueueEmpty("There are no items currently in this queue.") + + track: Playable = self._items.pop(0) self._loaded = track return track - def get(self) -> Playable: - """Retrieve a track from the left side of the queue. E.g. the first. + def get_at(self, index: int, /) -> Playable: + """Retrieve a track from the queue at a given index. - This method does not block. + .. warning:: + + Due to the way the queue loop works, this method will load the retrieved track for looping. + + Do **NOT** use this method to remove tracks from the queue, use either: + + - ``del queue[index]`` + - :meth:`wavelink.Queue.remove` + - :meth:`wavelink.Queue.delete` + + Parameters + ---------- + index: int + The index of the track to get. Returns ------- :class:`wavelink.Playable` The track retrieved from the queue. - Raises ------ QueueEmpty The queue was empty when retrieving a track. + IndexError + The index was out of range for the current queue. + """ + + if not self: + raise QueueEmpty("There are no items currently in this queue.") + + track: Playable = self._items.pop(index) + self._loaded = track + + return track + + def put_at(self, index: int, value: Playable, /) -> None: + """Put a track into the queue at a given index. + + .. note:: + + This method doesn't replace the track at the index but rather inserts one there, similar to a list. + + Parameters + ---------- + index: int + The index to put the track at. + value: :class:`wavelink.Playable` + The track to put. + + Raises + ------ + TypeError + The track was not a :class:`wavelink.Playable`. """ - return self._get() + self._check_compatibility(value) + self._items.insert(index, value) + self._wakeup_next() async def get_wait(self) -> Playable: """This method returns the first :class:`wavelink.Playable` if one is present or @@ -237,8 +331,8 @@ async def get_wait(self) -> Playable: ------- :class:`wavelink.Playable` The track retrieved from the queue. - """ + while not self: loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() waiter: asyncio.Future[None] = loop.create_future() @@ -262,26 +356,47 @@ async def get_wait(self) -> Playable: return self.get() - def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + def put(self, item: list[Playable] | Playable | Playlist, /, *, atomic: bool = True) -> int: """Put an item into the end of the queue. - Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` + Accepts a :class:`wavelink.Playable`, :class:`wavelink.Playlist` or list[:class:`wavelink.Playble`]. Parameters ---------- - item: :class:`wavelink.Playable` | :class:`wavelink.Playlist` + item: :class:`wavelink.Playable` | :class:`wavelink.Playlist` | list[:class:`wavelink.Playble`] The item to enter into the queue. atomic: bool Whether the items should be inserted atomically. If set to ``True`` this method won't enter any tracks if - it encounters an error. Defaults to ``True`` - + it encounters an error. Defaults to ``True``. Returns ------- int - The amount of tracks added to the queue. + The number of tracks added to the queue. """ - added: int = super().put(item, atomic=atomic) + + added = 0 + + if isinstance(item, Iterable): + if atomic: + self._check_atomic(item) + self._items.extend(item) + added = len(item) + else: + + def try_compatibility(track: object) -> bool: + try: + return self._check_compatibility(track) + except TypeError: + return False + + passing_items = [track for track in item if try_compatibility(track)] + self._items.extend(passing_items) + added = len(passing_items) + else: + self._check_compatibility(item) + self._items.append(item) + added = 1 self._wakeup_next() return added @@ -289,7 +404,7 @@ def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomic: bool = True) -> int: """Put an item or items into the end of the queue asynchronously. - Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`] + Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. .. note:: @@ -301,59 +416,148 @@ async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomi The item or items to enter into the queue. atomic: bool Whether the items should be inserted atomically. If set to ``True`` this method won't enter any tracks if - it encounters an error. Defaults to ``True`` - + it encounters an error. Defaults to ``True``. Returns ------- int - The amount of tracks added to the queue. + The number of tracks added to the queue. """ + added: int = 0 async with self._lock: - if isinstance(item, list | Playlist): + if isinstance(item, Iterable): if atomic: - super()._check_atomic(item) + self._check_atomic(item) + self._items.extend(item) + return len(item) for track in item: try: - super()._put(track) - added += 1 + self._check_compatibility(track) except TypeError: pass + else: + self._items.append(track) + added += 1 await asyncio.sleep(0) else: - super()._put(item) + self._check_compatibility(item) + self._items.append(item) added += 1 await asyncio.sleep(0) self._wakeup_next() return added - async def delete(self, index: int, /) -> None: + def delete(self, index: int, /) -> None: """Method to delete an item in the queue by index. - This method is asynchronous and implements/waits for a lock. - Raises ------ IndexError No track exists at this index. - Examples -------- .. code:: python3 - await queue.delete(1) # Deletes the track at index 1 (The second track). + queue.delete(1) """ - async with self._lock: - self._queue.__delitem__(index) + + del self._items[index] + + def peek(self, index: int = 0, /) -> Playable: + """Method to peek at an item in the queue by index. + + .. note:: + + This does not change the queue or remove the item. + + Parameters + ---------- + index: int + The index to peek at. Defaults to ``0`` which is the next item in the queue. + + Returns + ------- + :class:`wavelink.Playable` + The track at the given index. + + Raises + ------ + QueueEmpty + There are no items currently in this queue. + IndexError + No track exists at the given index. + + + ..versionadded:: 3.2.0 + """ + if not self: + raise QueueEmpty("There are no items currently in this queue.") + + return self[index] + + def swap(self, first: int, second: int, /) -> None: + """Swap two items in the queue by index. + + Parameters + ---------- + first: int + The first index to swap with. + second: int + The second index to swap with. + + Returns + ------- + None + + Raises + ------ + IndexError + No track exists at the given index. + + Example + ------- + + .. code:: python3 + + # Swap the first and second tracks in the queue. + queue.swap(0, 1) + + + .. versionadded:: 3.2.0 + """ + self[first], self[second] = self[second], self[first] + + def index(self, item: Playable, /) -> int: + """Return the index of the first occurence of a :class:`wavelink.Playable` in the queue. + + Parameters + ---------- + item: :class:`wavelink.Playable` + The item to search the index for. + + Returns + ------- + int + The index of the item in the queue. + + Raises + ------ + ValueError + The item was not found in the queue. + + + .. versionadded:: 3.2.0 + """ + return self._items.index(item) def shuffle(self) -> None: """Shuffles the queue in place. This does **not** return anything. @@ -365,41 +569,141 @@ def shuffle(self) -> None: player.queue.shuffle() # Your queue has now been shuffled... + + Returns + ------- + None """ - random.shuffle(self._queue) + + random.shuffle(self._items) def clear(self) -> None: """Remove all items from the queue. - .. note:: - This does not reset the queue. + This does not reset the queue or clear history. Use this method on queue.history to clear history. Example ------- - .. code:: python3 player.queue.clear() # Your queue is now empty... + + Returns + ------- + None + """ + + self._items.clear() + + def copy(self) -> Queue: + """Create a shallow copy of the queue. + + Returns + ------- + :class:`wavelink.Queue` + A shallow copy of the queue. + """ + + copy_queue = Queue(history=self.history is not None) + copy_queue._items = self._items.copy() + return copy_queue + + def reset(self) -> None: + """Reset the queue to its default state. This will clear the queue and history. + + .. note:: + + This will cancel any waiting futures on the queue. E.g. :meth:`wavelink.Queue.get_wait`. + + Returns + ------- + None + """ + self.clear() + if self.history is not None: + self.history.clear() + + for waiter in self._waiters: + waiter.cancel() + + self._waiters.clear() + + self._mode: QueueMode = QueueMode.normal + self._loaded = None + + def remove(self, item: Playable, /, count: int | None = 1) -> int: + """Remove a specific track from the queue up to a given count or all instances. + + .. note:: + + This method starts from the left hand side of the queue E.g. the beginning. + + .. warning:: + + Setting count to ``<= 0`` is equivalent to setting it to ``1``. + + Parameters + ---------- + item: :class:`wavelink.Playable` + The item to remove from the queue. + count: int + The amount of times to remove the item from the queue. Defaults to ``1``. + If set to ``None`` this will remove all instances of the item. + + Returns + ------- + int + The amount of times the item was removed from the queue. + + Raises + ------ + ValueError + The item was not found in the queue. + + + .. versionadded:: 3.2.0 """ - self._queue.clear() + deleted_count: int = 0 + + for track in self._items.copy(): + if track == item: + self._items.remove(track) + deleted_count += 1 + + if count is not None and deleted_count >= count: + break + + return deleted_count @property - def mode(self) -> QueueMode: - """Property which returns a :class:`~wavelink.QueueMode` indicating which mode the - :class:`~wavelink.Queue` is in. + def loaded(self) -> Playable | None: + """The currently loaded track that will repeat when the queue is set to :attr:`wavelink.QueueMode.loop`. - This property can be set with any :class:`~wavelink.QueueMode`. + This track will be retrieved when using :meth:`wavelink.Queue.get` if the queue is in loop mode. + You can unload the track by setting this property to ``None`` or by using :meth:`wavelink.Player.skip` with + ``force=True``. - .. versionadded:: 3.0.0 + Setting this property to a new :class:`wavelink.Playable` will replace the currently loaded track, but will not + add it to the queue; or history until the track is played. + + Returns + ------- + :class:`wavelink.Playable` | None + The currently loaded track or ``None`` if there is no track ready to repeat. + + Raises + ------ + TypeError + The track was not a :class:`wavelink.Playable` or ``None``. """ - return self._mode + return self._loaded - @mode.setter - def mode(self, value: QueueMode) -> None: - if not hasattr(self, "_mode"): - raise AttributeError("This queues mode can not be set.") + @loaded.setter + def loaded(self, value: Playable | None) -> None: + if value is not None: + self._check_compatibility(value) - self._mode = value + self._loaded = value