示例#1
0
    def __init__(self, manager) -> None:
        super().__init__(manager, "wled")

        self.led_count = storage.get("wled_led_count")

        self.ip = storage.get("wled_ip")
        if not self.ip:
            try:
                device = util.get_devices()[0]
                broadcast = util.broadcast_of_device(device)
                self.ip = broadcast
            except Exception:  # pylint: disable=broad-except
                # we don't want the startup to fail
                # just because the broadcast address could not be determined
                self.ip = "127.0.0.1"
            storage.put("wled_ip", self.ip)
        self.port = storage.get("wled_port")

        self.header = bytes([
            2,  # DRGB protocol, we update every led every frame
            1,  # wait 1 second after the last packet until resuming normally
        ])

        self.initialized = True
        redis.put("wled_initialized", True)
示例#2
0
    def request(self,
                session_key: str,
                archive: bool = True,
                manually_requested: bool = True) -> None:
        """Tries to request this resource.
        Uses the local cache if possible, otherwise tries to retrieve it online."""

        if 0 < storage.get("max_queue_length") <= playback.queue.count():
            self.error = "Queue limit reached"
            raise ProviderError(self.error)

        enqueue_function = enqueue

        if not self.check_cached():
            if self.query is not None and storage.get("additional_keywords"):
                # add the additional keywords from the settings before checking
                self.query += " " + storage.get("additional_keywords")
            if not self.check_available():
                raise ProviderError(self.error)

            # overwrite the enqueue function and make the resource available before calling it
            enqueue_function = fetch_enqueue

        if storage.get("new_music_only"):
            if self.was_requested_before():
                self.error = "Only new music is allowed!"
                raise ProviderError(self.error)

        if storage.get("song_cooldown") != 0:
            if self.on_cooldown():
                raise ProviderError(self.error)

        self.enqueue_placeholder(manually_requested)

        enqueue_function.delay(self, session_key, archive)
示例#3
0
    def __init__(self) -> None:

        self.loop_active: Optional[Event] = Event()

        self.devices = Devices(Ring(self), Strip(self), WLED(self),
                               Screen(self))

        # these settings are mirrored from the database,
        # because some of them are accessed multiple times per update.
        self.settings: Settings = {
            "ups": storage.get("ups"),
            "dynamic_resolution": storage.get("dynamic_resolution"),
            "program_speed": storage.get("program_speed"),
            "fixed_color": storage.get("fixed_color"),
            "last_fixed_color": storage.get("fixed_color"),
        }

        self.utilities = Utilities(Disabled(self), Cava(self), Alarm(self))
        cava_installed = shutil.which("cava") is not None

        # a dictionary containing all led programs by their name
        led_programs: Dict[str, LedProgram] = {
            self.utilities.disabled.name: self.utilities.disabled
        }
        led_program_classes = [Fixed, Rainbow]
        if cava_installed:
            led_program_classes.append(Adaptive)
        for led_program_class in led_program_classes:
            led_program = led_program_class(self)
            led_programs[led_program.name] = led_program
        # a dictionary containing all screen programs by their name
        screen_programs: Dict[str, ScreenProgram] = {
            self.utilities.disabled.name: self.utilities.disabled
        }
        logo_loop = Video(self, "LogoLoop.mp4", loop=True)
        screen_programs[logo_loop.name] = logo_loop
        if cava_installed:
            for variant in sorted(Visualization.get_variants()):
                screen_programs[variant] = Visualization(self, variant)

        redis.put("led_programs", list(led_programs.keys()))
        redis.put("screen_programs", list(screen_programs.keys()))

        # a dictionary containing *all* programs by their name
        self.programs: Dict[str, LightProgram] = {
            **led_programs,
            **screen_programs
        }

        for device in self.devices:
            device.load_program()

        self.consumers_changed()

        self.listener = Thread(target=self.listen_for_changes)
        self.listener.start()
示例#4
0
 def __init__(self, manager, name) -> None:
     self.manager = manager
     self.name = name
     assert self.name in ["ring", "strip", "wled", "screen"]
     self.brightness = storage.get(
         cast(DeviceBrightness, f"{self.name}_brightness"))
     self.monochrome = storage.get(
         cast(DeviceMonochrome, f"{self.name}_monochrome"))
     self.initialized = False
     redis.put(cast(DeviceInitialized, f"{self.name}_initialized"), False)
     self.program: LightProgram = Disabled(manager)
示例#5
0
    def create(query: Optional[str] = None,
               key: Optional[int] = None) -> "PlaylistProvider":
        """Factory method to create a playlist provider.
        Both query and key need to be specified.
        Detects the type of provider needed and returns one of corresponding type."""
        if query is None:
            logging.error(
                "archived playlist requested but no query given (key %s)", key)
            raise ValueError
        if key is None:
            logging.error("archived playlist requested but no key given")
            raise ValueError
        try:
            archived_playlist = ArchivedPlaylist.objects.get(id=key)
        except ArchivedPlaylist.DoesNotExist as error:
            logging.error("archived song requested for nonexistent key %s",
                          key)
            raise ValueError from error

        playlist_type = song_utils.determine_playlist_type(archived_playlist)
        provider_class: Optional[Type[PlaylistProvider]] = None
        if playlist_type == "local":
            from core.musiq.local import LocalPlaylistProvider

            provider_class = LocalPlaylistProvider
        elif storage.get("youtube_enabled") and playlist_type == "youtube":
            from core.musiq.youtube import YoutubePlaylistProvider

            provider_class = YoutubePlaylistProvider
        elif storage.get("spotify_enabled") and playlist_type == "spotify":
            from core.musiq.spotify import SpotifyPlaylistProvider

            provider_class = SpotifyPlaylistProvider
        elif storage.get(
                "soundcloud_enabled") and playlist_type == "soundcloud":
            from core.musiq.soundcloud import SoundcloudPlaylistProvider

            provider_class = SoundcloudPlaylistProvider
        elif storage.get("jamendo_enabled") and playlist_type == "jamendo":
            from core.musiq.jamendo import JamendoPlaylistProvider

            provider_class = JamendoPlaylistProvider
        elif playlist_type == "playlog":
            # The playlist may contain various song types, but all of them will be archived.
            # We can use the local playlist provider to enqueue them.
            from core.musiq.local import LocalPlaylistProvider

            provider_class = LocalPlaylistProvider
        if not provider_class:
            raise NotImplementedError(
                f"No provider for given playlist: {query}, {key}")
        provider = provider_class(query, key)
        return provider
示例#6
0
    def create(
        query: Optional[str] = None,
        key: Optional[int] = None,
        external_url: Optional[str] = None,
    ) -> "SongProvider":
        """Factory method to create a song provider.
        Either (query and key) or external url need to be specified.
        Detects the type of provider needed and returns one of corresponding type."""
        if key is not None:
            if query is None:
                logging.error(
                    "archived song requested but no query given (key %s)", key)
                raise ValueError()
            try:
                archived_song = ArchivedSong.objects.get(id=key)
            except ArchivedSong.DoesNotExist as error:
                logging.error("archived song requested for nonexistent key %s",
                              key)
                raise ValueError() from error
            external_url = archived_song.url
        if external_url is None:
            raise ValueError(
                "external_url was provided and could not be inferred from remaining attributes."
            )
        provider_class: Optional[Type[SongProvider]] = None
        url_type = song_utils.determine_url_type(external_url)
        if url_type == "local":
            from core.musiq.local import LocalSongProvider

            provider_class = LocalSongProvider
        elif storage.get("youtube_enabled") and url_type == "youtube":
            from core.musiq.youtube import YoutubeSongProvider

            provider_class = YoutubeSongProvider
        elif storage.get("spotify_enabled") and url_type == "spotify":
            from core.musiq.spotify import SpotifySongProvider

            provider_class = SpotifySongProvider
        elif storage.get("soundcloud_enabled") and url_type == "soundcloud":
            from core.musiq.soundcloud import SoundcloudSongProvider

            provider_class = SoundcloudSongProvider
        elif storage.get("jamendo_enabled") and url_type == "jamendo":
            from core.musiq.jamendo import JamendoSongProvider

            provider_class = JamendoSongProvider
        if not provider_class:
            raise NotImplementedError(
                f"No provider for given song: {external_url}")
        if not query and external_url:
            query = external_url
        provider = provider_class(query, key)
        return provider
示例#7
0
 def web_client(self) -> OAuthClient:
     """Returns the web client if it was already created.
     If not, it is created using the spotify credentials from the database."""
     if Spotify._web_client is None:
         client_id = storage.get(key="spotify_client_id")
         client_secret = storage.get(key="spotify_client_secret")
         Spotify._web_client = OAuthClient(
             base_url="https://api.spotify.com/v1",
             refresh_url="https://auth.mopidy.com/spotify/token",
             client_id=client_id,
             client_secret=client_secret,
         )
     return Spotify._web_client
示例#8
0
    def persist(self, session_key: str, archive: bool = True) -> None:
        metadata = self.get_metadata()

        # Increase counter of song/playlist
        with transaction.atomic():
            queryset = ArchivedSong.objects.filter(
                url=metadata["external_url"])
            if queryset.count() == 0:
                initial_counter = 1 if archive else 0
                assert metadata["external_url"]
                archived_song = ArchivedSong.objects.create(
                    url=metadata["external_url"],
                    artist=metadata["artist"],
                    title=metadata["title"],
                    duration=metadata["duration"],
                    counter=initial_counter,
                    cached=metadata["cached"],
                )
            else:
                if archive:
                    queryset.update(counter=F("counter") + 1)
                archived_song = queryset.get()

            if archive:
                ArchivedQuery.objects.get_or_create(song=archived_song,
                                                    query=self.query)

        if storage.get("logging_enabled") and session_key:
            RequestLog.objects.create(song=archived_song,
                                      session_key=session_key)
示例#9
0
    def persist(self, session_key: str, archive: bool = True) -> None:
        if self.is_radio():
            return

        assert self.id
        if self.title is None:
            logging.warning("Persisting a playlist with no title (id %s)",
                            self.id)
            self.title = ""

        with transaction.atomic():
            queryset = ArchivedPlaylist.objects.filter(list_id=self.id)
            if queryset.count() == 0:
                initial_counter = 1 if archive else 0
                archived_playlist = ArchivedPlaylist.objects.create(
                    list_id=self.id, title=self.title, counter=initial_counter)
                for index, url in enumerate(self.urls):
                    PlaylistEntry.objects.create(playlist=archived_playlist,
                                                 index=index,
                                                 url=url)
            else:
                if archive:
                    queryset.update(counter=F("counter") + 1)
                archived_playlist = queryset.get()

        if archive:
            ArchivedPlaylistQuery.objects.get_or_create(
                playlist=archived_playlist, query=self.query)

        if storage.get("logging_enabled") and session_key:
            RequestLog.objects.create(playlist=archived_playlist,
                                      session_key=session_key)
示例#10
0
    def fetch_metadata(self) -> bool:
        # in case of a radio playlist, restrict the number of songs that are downloaded
        assert self.id
        if self.is_radio():
            self.ydl_opts["playlistend"] = storage.get("max_playlist_items")
            # radios are not viewable with the /playlist?list= url,
            # create a video watch url with the radio list
            query_url = ("https://www.youtube.com/watch?v=" + self.id[2:] +
                         "&list=" + self.id)
        else:
            # if only given the id, yt-dlp returns an info dict resolving this id to a url.
            # we want to receive the playlist entries directly, so we query the playlist url
            query_url = "https://www.youtube.com/playlist?list=" + self.id

        try:
            with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
                info_dict = ydl.extract_info(query_url, download=False)
        except (yt_dlp.utils.ExtractorError,
                yt_dlp.utils.DownloadError) as error:
            self.error = error
            return False

        if info_dict["_type"] != "playlist" or "entries" not in info_dict:
            # query was not a playlist url -> search for the query
            assert False

        assert self.id == info_dict["id"]
        if "title" in info_dict:
            self.title = info_dict["title"]
        for entry in info_dict["entries"]:
            self.urls.append("https://www.youtube.com/watch?v=" + entry["id"])
        assert self.key is None

        return True
示例#11
0
def try_providers(session_key: str,
                  providers: List[MusicProvider]) -> MusicProvider:
    """Goes through every given provider and tries to request its music.
    Returns the first provider that was successful with an empty error.
    If unsuccessful, return the last provider."""

    fallback = False
    last_provider = providers[-1]
    provider = providers[0]
    for provider in providers:
        try:
            provider.request(session_key)
            # the current provider could provide the song, don't try the other ones
            break
        except ProviderError:
            # this provider cannot provide this song, use the next provider
            # if this was the last provider, show its error
            # in new music only mode, do not allow fallbacks
            if storage.get("new_music_only") or provider == last_provider:
                return provider
            fallback = True
    provider.error = ""
    if fallback:
        provider.ok_message += " (used fallback)"
    return provider
示例#12
0
 def check_not_too_large(self, size: Optional[float]) -> bool:
     """Returns whether the the given size is small enough in order for the song to be played."""
     max_size = storage.get("max_download_size") * 1024 * 1024
     if (max_size != 0 and not self.check_cached()
             and (size is not None and size > max_size)):
         self.error = "Song too long"
         return False
     return True
示例#13
0
def _handle_program_request(device: str, request: WSGIRequest) -> HttpResponse:
    program, response = extract_value(request.POST)
    assert device in ["ring", "strip", "wled", "screen"]
    if program == storage.get(cast(DeviceProgram, f"{device}_program")):
        # the program doesn't change, return immediately
        return HttpResponse()
    set_program(device, program)
    return response
示例#14
0
 def check_cached(self) -> bool:
     if not self.id:
         # id could not be extracted from query, needs to be serched
         return False
     if storage.get("dynamic_embedded_stream"):
         # youtube streaming links need to be fetched each time the song is requested
         return False
     return os.path.isfile(self.get_path())
示例#15
0
def enabled_platforms_by_priority() -> List[str]:
    """Returns a list of all available platforms, ordered by priority."""
    # local music can only be searched explicitly by key and thus is last
    return [
        platform for platform in
        ["spotify", "youtube", "soundcloud", "jamendo", "local"]
        if storage.get(cast(PlatformEnabled, f"{platform}_enabled"))
    ]
示例#16
0
def update_mopidy_config(output: str) -> None:
    """Updates mopidy's config with the credentials stored in the database.
    If no config_file is given, the default one is used."""
    if settings.DOCKER:
        # raveberry cannot restart mopidy in the docker setup
        return

    if output == "pulse":
        if storage.get("feed_cava") and shutil.which("cava"):
            output = "cava"
        else:
            output = "regular"

    spotify_username = storage.get("spotify_username")
    spotify_password = storage.get("spotify_password")
    spotify_client_id = storage.get("spotify_client_id")
    spotify_client_secret = storage.get("spotify_client_secret")
    soundcloud_auth_token = storage.get("soundcloud_auth_token")
    jamendo_client_id = storage.get("jamendo_client_id")

    subprocess.call([
        "sudo",
        "/usr/local/sbin/raveberry/update_mopidy_config",
        output,
        spotify_username,
        spotify_password,
        spotify_client_id,
        spotify_client_secret,
        soundcloud_auth_token,
        jamendo_client_id,
    ])
    restart_mopidy()
示例#17
0
def set_lights_shortcut(request: WSGIRequest) -> HttpResponse:
    """Stores the current lights state and restores the previous one."""
    value, response = extract_value(request.POST)
    should_enable = strtobool(value)
    is_enabled = (storage.get("ring_program") != "Disabled"
                  or storage.get("wled_program") != "Disabled"
                  or storage.get("strip_program") != "Disabled")
    if should_enable == is_enabled:
        return HttpResponse()
    if should_enable:
        for device in ["ring", "wled", "strip"]:
            set_program(
                device,
                storage.get(cast(DeviceProgram, f"last_{device}_program")))
    else:
        for device in ["ring", "wled", "strip"]:
            set_program(device, "Disabled")
    return response
示例#18
0
def _offline_playlist_suggestions(query: str) -> List[SuggestionResult]:
    results: List[SuggestionResult] = []
    terms = query.split()
    remaining_playlists = ArchivedPlaylist.objects.prefetch_related("queries")
    # exclude radios from suggestions
    remaining_playlists = remaining_playlists.exclude(
        list_id__startswith="RD").exclude(list_id__contains="&list=RD")

    # we could have more strict types with TypedDicts,
    # but massaging both QuerySets into a single Type would require asserts/casts,
    # which have little performance impact but should be avoided in this critical codepath
    playlist_results: Iterable[Mapping[str, Any]]
    if settings.DEBUG:
        matching_playlists = remaining_playlists
        for term in terms:
            matching_playlists = matching_playlists.filter(
                Q(title__icontains=term) | Q(queries__query__icontains=term))

        playlist_results = (matching_playlists.order_by("-counter").values(
            "id", "title",
            "counter").distinct()[:storage.get("number_of_suggestions")])
    else:
        from django.contrib.postgres.search import TrigramWordSimilarity

        similar_playlists = remaining_playlists.annotate(
            title_similarity=TrigramWordSimilarity(query, "title"),
            query_similarity=TrigramWordSimilarity(query, "queries__query"),
            max_similarity=Greatest("title_similarity", "query_similarity"),
        )

        playlist_results = (
            similar_playlists.order_by("-max_similarity").values(
                "id", "title",
                "counter").distinct()[:storage.get("number_of_suggestions")])
    for playlist in playlist_results:
        archived_playlist = ArchivedPlaylist.objects.get(id=playlist["id"])
        result_dict: SuggestionResult = {
            "key": playlist["id"],
            "value": playlist["title"],
            "counter": playlist["counter"],
            "type": song_utils.determine_playlist_type(archived_playlist),
        }
        results.append(result_dict)
    return results
示例#19
0
    def extract_id(self) -> Optional[str]:
        """Tries to extract the id from the given query.
        Returns the id if possible, otherwise None"""
        if self.key is not None:
            try:
                archived_song = ArchivedSong.objects.get(id=self.key)
                return self.__class__.get_id_from_external_url(
                    archived_song.url)
            except ArchivedSong.DoesNotExist:
                return None
        if self.query is not None:
            url_type = song_utils.determine_url_type(self.query)
            provider_class: Optional[Type[SongProvider]] = None
            if url_type == "local":
                from core.musiq.local import LocalSongProvider

                provider_class = LocalSongProvider
            if storage.get("youtube_enabled") and url_type == "youtube":
                from core.musiq.youtube import YoutubeSongProvider

                provider_class = YoutubeSongProvider
            if storage.get("spotify_enabled") and url_type == "spotify":
                from core.musiq.spotify import SpotifySongProvider

                provider_class = SpotifySongProvider
            if storage.get("soundcloud_enabled") and url_type == "soundcloud":
                from core.musiq.soundcloud import SoundcloudSongProvider

                provider_class = SoundcloudSongProvider
            if storage.get("jamendo_enabled") and url_type == "jamendo":
                from core.musiq.jamendo import JamendoSongProvider

                provider_class = JamendoSongProvider
            if provider_class is not None:
                return provider_class.get_id_from_external_url(self.query)
            try:
                archived_song = ArchivedSong.objects.get(url=self.query)
                return self.__class__.get_id_from_external_url(
                    archived_song.url)
            except ArchivedSong.DoesNotExist:
                return None
        logging.error(
            "Can not extract id because neither key nor query are known")
        return None
示例#20
0
def vote(request: WSGIRequest) -> HttpResponse:
    """Modify the vote-count of the given song by the given amount.
    If a song receives too many downvotes, it is removed."""
    key_param = request.POST.get("key")
    amount_param = request.POST.get("amount")
    if key_param is None or amount_param is None:
        return HttpResponseBadRequest()
    key = int(key_param)
    amount = int(amount_param)
    if amount < -2 or amount > 2:
        return HttpResponseBadRequest()

    if storage.get("ip_checking") and not user_manager.try_vote(
            user_manager.get_client_ip(request), key, amount):
        return HttpResponseBadRequest("nice try")

    models.CurrentSong.objects.filter(queue_key=key).update(votes=F("votes") +
                                                            amount)
    try:
        current_song = models.CurrentSong.objects.get()
        if (current_song.queue_key == key
                and current_song.votes <= -storage.get(  # pylint: disable=invalid-unary-operand-type
                    "downvotes_to_kick")):
            with playback.mopidy_command() as allowed:
                if allowed:
                    PLAYER.playback.next()
    except models.CurrentSong.DoesNotExist:
        pass

    removed = playback.queue.vote(
        key,
        amount,
        -storage.get("downvotes_to_kick"),  # pylint: disable=invalid-unary-operand-type
    )
    # if we removed a song by voting, and it was added by autoplay,
    # we want it to be the new basis for autoplay
    if removed is not None:
        if not removed.manually_requested:
            playback.handle_autoplay(removed.external_url or removed.title)
        else:
            playback.handle_autoplay()
    musiq.update_state()
    return HttpResponse()
示例#21
0
 def adjust(self) -> None:
     """Updates resolutions and resets the current one.
     Needed after changing screens or hotplugging after booting without a connected screen."""
     self.resolution = storage.get("initial_resolution")
     resolutions = list(reversed(sorted(self.list_resolutions())))
     redis.put("resolutions", resolutions)
     # if unset, initialize with the highest resolution
     if self.resolution == (0, 0):
         storage.put("initial_resolution", resolutions[0])
         self.resolution = resolutions[0]
     self.set_resolution(self.resolution)
示例#22
0
 def _decorator(request: WSGIRequest) -> HttpResponse:
     if storage.get(
             "interactivity"
     ) != storage.Interactivity.full_control and not user_manager.has_controls(
             request.user):
         return HttpResponseForbidden()
     response = func(request)
     musiq.update_state()
     if response is not None:
         return response
     return HttpResponse()
示例#23
0
def _check_internet() -> None:
    host = storage.get("connectivity_host")
    if not host:
        redis.put("has_internet", False)
        return
    response = subprocess.call(
        ["ping", "-c", "1", "-W", "3", host], stdout=subprocess.DEVNULL
    )
    if response == 0:
        redis.put("has_internet", True)
    else:
        redis.put("has_internet", False)
示例#24
0
def submit_hashtag(request: WSGIRequest) -> HttpResponse:
    """Add the given hashtag to the database."""
    hashtag = request.POST.get("hashtag")
    if hashtag is None or len(hashtag) == 0:
        return HttpResponseBadRequest()

    if hashtag[0] != "#":
        hashtag = "#" + hashtag
    models.Tag.objects.create(text=hashtag,
                              active=storage.get("hashtags_active"))

    return HttpResponse()
示例#25
0
        def fetch_soundcloud() -> None:
            from core.musiq.soundcloud import Soundcloud

            soundcloud_suggestions = Soundcloud().get_search_suggestions(query)
            soundcloud_suggestions = soundcloud_suggestions[:storage.get(
                "soundcloud_suggestions")]
            with results_lock:
                for suggestion in soundcloud_suggestions:
                    results.append({
                        "key": -1,
                        "value": suggestion,
                        "type": "soundcloud-online"
                    })
示例#26
0
def is_forbidden(string: str) -> bool:
    """Returns whether the given string should be filtered according to the forbidden keywords."""
    # We can't access the variable in settings/basic.py
    # since we are in a static context without a reference to bes
    keywords = storage.get("forbidden_keywords")
    words = re.split(r"[,\s]+", keywords.strip())
    # delete empty matches
    words = [word for word in words if word]

    for word in words:
        if re.search(word, string, re.IGNORECASE):
            return True
    return False
示例#27
0
        def fetch_jamendo() -> None:
            from core.musiq.jamendo import Jamendo

            jamendo_suggestions = Jamendo().get_search_suggestions(query)
            jamendo_suggestions = jamendo_suggestions[:storage.get(
                "jamendo_suggestions")]
            with results_lock:
                for suggestion in jamendo_suggestions:
                    results.append({
                        "key": -1,
                        "value": suggestion,
                        "type": "jamendo-online"
                    })
示例#28
0
        def fetch_youtube() -> None:
            from core.musiq.youtube import Youtube

            youtube_suggestions = Youtube().get_search_suggestions(query)
            youtube_suggestions = youtube_suggestions[:storage.get(
                "youtube_suggestions")]
            with results_lock:
                for suggestion in youtube_suggestions:
                    results.append({
                        "key": -1,
                        "value": suggestion,
                        "type": "youtube-online"
                    })
示例#29
0
    def load_program(self) -> None:
        """Load and activate this device's program from the database."""
        assert self.name in ["ring", "strip", "wled", "screen"]
        program_name = storage.get(
            cast(DeviceBrightness, f"{self.name}_program"))

        # only enable if the device is initialized
        if self.initialized:
            self.program = self.manager.programs[program_name]
        else:
            self.program = self.manager.utilities.disabled

        self.program.use()
示例#30
0
        def fetch_spotify() -> None:
            from core.musiq.spotify import Spotify

            spotify_suggestions = Spotify().get_search_suggestions(
                query, suggest_playlist)
            spotify_suggestions = spotify_suggestions[:storage.get(
                "spotify_suggestions")]
            with results_lock:
                for suggestion, external_url in spotify_suggestions:
                    results.append({
                        "key": external_url,
                        "value": suggestion,
                        "type": "spotify-online",
                    })