diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 8623d398..d77018b9 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -19,6 +19,8 @@ Search ------ .. automethod:: YTMusic.search .. automethod:: YTMusic.get_search_suggestions +.. automethod:: YTMusic.remove_search_suggestions + Browsing -------- diff --git a/tests/mixins/test_search.py b/tests/mixins/test_search.py index 4d5c1808..c52d2045 100644 --- a/tests/mixins/test_search.py +++ b/tests/mixins/test_search.py @@ -1,6 +1,7 @@ import pytest from ytmusicapi import YTMusic +from ytmusicapi.exceptions import YTMusicUserError from ytmusicapi.parsers.search import ALL_RESULT_TYPES @@ -115,3 +116,31 @@ def test_search_library(self, config, yt_oauth): yt_oauth.search("beatles", filter="community_playlists", scope="library", limit=40) with pytest.raises(Exception): yt_oauth.search("beatles", filter="featured_playlists", scope="library", limit=40) + + def test_remove_search_suggestions_valid(self, yt_auth): + first_pass = yt_auth.search("b") # Populate the suggestion history + assert len(first_pass) > 0, "Search returned no results" + + results = yt_auth.get_search_suggestions("b", detailed_runs=True) + assert len(results) > 0, "No search suggestions returned" + assert any(item.get("fromHistory") for item in results), "No suggestions from history found" + + response = yt_auth.remove_search_suggestions(results) + assert response is True, "Failed to remove search suggestions" + + def test_remove_search_suggestions_errors(self, yt_auth, yt): + first_pass = yt_auth.search("a") + assert len(first_pass) > 0, "Search returned no results" + + results = yt_auth.get_search_suggestions("a", detailed_runs=True) + assert len(results) > 0, "No search suggestions returned" + assert any(item.get("fromHistory") for item in results), "No suggestions from history found" + + suggestion_to_remove = [99] + with pytest.raises(YTMusicUserError, match="Index out of range."): + yt_auth.remove_search_suggestions(results, suggestion_to_remove) + + suggestion_to_remove = [0] + with pytest.raises(YTMusicUserError, match="No search result from history provided."): + results = yt.get_search_suggestions("a", detailed_runs=True) + yt.remove_search_suggestions(results, suggestion_to_remove) diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 96074616..08399e12 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -268,7 +268,8 @@ def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[list[ suggestion along with the complete text (like many search services usually bold the text typed by the user). Default: False, returns the list of search suggestions in plain text. - :return: List of search suggestion results depending on ``detailed_runs`` param. + :return: A list of search suggestions. If ``detailed_runs`` is False, it returns plain text suggestions. + If ``detailed_runs`` is True, it returns a list of dictionaries with detailed information. Example response when ``query`` is 'fade' and ``detailed_runs`` is set to ``False``:: @@ -295,7 +296,9 @@ def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[list[ { "text": "d" } - ] + ], + "fromHistory": true, + "feedbackToken": "AEEJK..." }, { "text": "faded alan walker lyrics", @@ -307,7 +310,9 @@ def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[list[ { "text": "d alan walker lyrics" } - ] + ], + "fromHistory": false, + "feedbackToken": None }, { "text": "faded alan walker", @@ -319,16 +324,64 @@ def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[list[ { "text": "d alan walker" } - ] + ], + "fromHistory": false, + "feedbackToken": None }, ... ] """ - body = {"input": query} endpoint = "music/get_search_suggestions" response = self._send_request(endpoint, body) - search_suggestions = parse_search_suggestions(response, detailed_runs) - return search_suggestions + return parse_search_suggestions(response, detailed_runs) + + def remove_search_suggestions( + self, suggestions: list[dict[str, Any]], indices: Optional[list[int]] = None + ) -> bool: + """ + Remove search suggestion from the user search history. + + :param suggestions: The dictionary obtained from the :py:func:`get_search_suggestions` + (with detailed_runs=True)` + :param indices: Optional. The indices of the suggestions to be removed. Default: remove all suggestions. + :return: True if the operation was successful, False otherwise. + + Example usage:: + + # Removing suggestion number 0 + suggestions = ytmusic.get_search_suggestions(query="fade", detailed_runs=True) + success = ytmusic.remove_search_suggestions(suggestions=suggestions, indices=[0]) + if success: + print("Suggestion removed successfully") + else: + print("Failed to remove suggestion") + """ + if not any(run["fromHistory"] for run in suggestions): + raise YTMusicUserError( + "No search result from history provided. " + "Please run get_search_suggestions first to retrieve suggestions. " + "Ensure that you have searched a similar term before." + ) + + if indices is None: + indices = list(range(len(suggestions))) + + if any(index >= len(suggestions) for index in indices): + raise YTMusicUserError("Index out of range. Index must be smaller than the length of suggestions") + + feedback_tokens = [suggestions[index]["feedbackToken"] for index in indices] + if all(feedback_token is None for feedback_token in feedback_tokens): + return False + + # filter None tokens + feedback_tokens = [token for token in feedback_tokens if token is not None] + + body = {"feedbackTokens": feedback_tokens} + endpoint = "feedback" + + response = self._send_request(endpoint, body) + + return bool(nav(response, ["feedbackResponses", 0, "isProcessed"], none_if_absent=True)) diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index a8b87078..df62eb1b 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -1,3 +1,5 @@ +from typing import Union + from ..helpers import to_int from ._utils import * from .songs import * @@ -257,26 +259,38 @@ def _get_param2(filter): return filter_params[filter] -def parse_search_suggestions(results, detailed_runs): +def parse_search_suggestions( + results: dict[str, Any], detailed_runs: bool +) -> Union[list[str], list[dict[str, Any]]]: if not results.get("contents", [{}])[0].get("searchSuggestionsSectionRenderer", {}).get("contents", []): return [] raw_suggestions = results["contents"][0]["searchSuggestionsSectionRenderer"]["contents"] suggestions = [] - for raw_suggestion in raw_suggestions: + for index, raw_suggestion in enumerate(raw_suggestions): + feedback_token = None if "historySuggestionRenderer" in raw_suggestion: suggestion_content = raw_suggestion["historySuggestionRenderer"] - from_history = True + # Extract feedbackToken if present + feedback_token = nav( + suggestion_content, ["serviceEndpoint", "feedbackEndpoint", "feedbackToken"], True + ) else: suggestion_content = raw_suggestion["searchSuggestionRenderer"] - from_history = False text = suggestion_content["navigationEndpoint"]["searchEndpoint"]["query"] runs = suggestion_content["suggestion"]["runs"] if detailed_runs: - suggestions.append({"text": text, "runs": runs, "fromHistory": from_history}) + suggestions.append( + { + "text": text, + "runs": runs, + "fromHistory": feedback_token is not None, + "feedbackToken": feedback_token, + } + ) else: suggestions.append(text)