Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into docstring-cleanup
Browse files Browse the repository at this point in the history
# Conflicts:
#	ytmusicapi/mixins/search.py
  • Loading branch information
sigma67 committed Dec 17, 2024
2 parents 1b493b9 + 7441114 commit 92cbcfa
Show file tree
Hide file tree
Showing 34 changed files with 704 additions and 418 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ jobs:
- uses: actions/checkout@v4
- uses: chartboost/ruff-action@v1
with:
version: 0.4.3
version: 0.8.3
- uses: chartboost/ruff-action@v1
with:
version: 0.4.3
version: 0.8.3
args: format --check
mypy:
runs-on: ubuntu-latest
Expand All @@ -27,5 +27,5 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.9"
- run: pip install mypy==1.10.0
- run: pip install mypy==1.13.0
- run: mypy --install-types --non-interactive
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ feel free to create an `issue <https://github.com/sigma67/ytmusicapi/issues/new/
Requirements
------------

- Python 3.8 or higher - https://www.python.org
- Python 3.9 or higher - https://www.python.org

Setup
-----
Expand Down
2 changes: 1 addition & 1 deletion docs/source/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ reached at least the ``limit`` parameter, and return all of these results.
Which values can I use for languages?
*************************************

The `language` parameter determines the language of the returned results.
The ``language`` parameter determines the language of the returned results.
``ytmusicapi`` only supports a subset of the languages supported by YouTube Music, as translations need to be done manually.
Contributions are welcome, see `here for instructions <https://github.com/sigma67/ytmusicapi/tree/master/ytmusicapi/locales>`__.

Expand Down
2 changes: 2 additions & 0 deletions docs/source/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Search
------
.. automethod:: YTMusic.search
.. automethod:: YTMusic.get_search_suggestions
.. automethod:: YTMusic.remove_search_suggestions


Browsing
--------
Expand Down
706 changes: 408 additions & 298 deletions pdm.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ dev = [
"coverage>=7.4.0",
'sphinx<7',
'sphinx-rtd-theme',
"ruff>=0.1.9",
"mypy>=1.8.0",
"ruff>=0.8.3",
"mypy>=1.13.0",
"pytest>=7.4.4",
"pytest-cov>=4.1.0",
"types-requests>=2.31.0.20240218",
Expand Down
2 changes: 1 addition & 1 deletion tests/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tests use the ``unittest`` framework. Each function has a corresponding unittest
Sometimes there is a single unittest for multiple functions to ensure there are no permanent changes in the user's
YouTube account (i.e. subscribe and unsubscribe).

Note that there must be a ``browser.json`` and ``oauth.json`` in the `tests` folder to run all authenticated tests.
Note that there must be a ``browser.json`` and ``oauth.json`` in the ``tests`` folder to run all authenticated tests.
These two files can be easily obtained as the default outputs of running the following commands respectively:

.. code-block:: bash
Expand Down
19 changes: 15 additions & 4 deletions tests/auth/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from ytmusicapi.auth.oauth import OAuthToken
from ytmusicapi.auth.types import AuthType
from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET
from ytmusicapi.setup import main
from ytmusicapi.ytmusic import OAuthCredentials, YTMusic

Expand All @@ -27,8 +26,8 @@ def fixture_blank_code() -> dict[str, Any]:


@pytest.fixture(name="alt_oauth_credentials")
def fixture_alt_oauth_credentials() -> OAuthCredentials:
return OAuthCredentials(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)
def fixture_alt_oauth_credentials(config) -> OAuthCredentials:
return OAuthCredentials(config["auth"]["client_id"], config["auth"]["client_secret"])


@pytest.fixture(name="yt_alt_oauth")
Expand All @@ -47,7 +46,19 @@ def test_setup_oauth(self, session_mock, json_mock, blank_code, config):
oauth_filepath = oauth_file.name
with (
mock.patch("builtins.input", return_value="y"),
mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath]),
mock.patch(
"sys.argv",
[
"ytmusicapi",
"oauth",
"--file",
oauth_filepath,
"--client-id",
"test_id",
"--client-secret",
"test_secret",
],
),
mock.patch("webbrowser.open"),
):
main()
Expand Down
8 changes: 6 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from ytmusicapi import YTMusic
from ytmusicapi.auth.oauth import OAuthCredentials


def get_resource(file: str) -> str:
Expand Down Expand Up @@ -56,8 +57,11 @@ def fixture_yt_auth(browser_filepath) -> YTMusic:


@pytest.fixture(name="yt_oauth")
def fixture_yt_oauth(oauth_filepath) -> YTMusic:
return YTMusic(oauth_filepath)
def fixture_yt_oauth(oauth_filepath, config) -> YTMusic:
credentials = OAuthCredentials(
client_id=config["auth"]["client_id"], client_secret=config["auth"]["client_secret"]
)
return YTMusic(oauth_filepath, oauth_credentials=credentials)


@pytest.fixture(name="yt_brand")
Expand Down
9 changes: 9 additions & 0 deletions tests/data/expected_output/2024_03_get_playlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "PLaZPMsuQNCsWn0iVMtGbaUXO6z-EdZaZm",
"title": "03 Jan 12:09",
"owned": true,
"trackCount": 245,
"year": "2024",
"description": "Created by Playlist Generator Bot, @spotify_youtube_playlist_bot",
"duration": "5+ hours"
}
9 changes: 9 additions & 0 deletions tests/data/expected_output/2024_03_get_playlist_public.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "RDCLAK5uy_lWy02cQBnTVTlwuRauaGKeUDH3L6PXNxI",
"title": "Feel-Good Classic Rock",
"owned": false,
"trackCount": 101,
"year": "2024",
"description": "Hold on to the feeling.\n#essentials #rock #happy",
"duration": "6+ hours"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "PLEUijtLfpCOgI8LNOwiwvq0EJ8HAGj7dT",
"title": "Example_collaborative_playlist",
"owned": true,
"trackCount": 4,
"year": "2024",
"description": "Example playlist with collaboration",
"duration": "11 minutes, 15 seconds"
}
9 changes: 9 additions & 0 deletions tests/mixins/test_browsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,26 @@ def test_get_album(self, yt, yt_auth, sample_album):
assert album["tracks"][0]["isExplicit"]
assert all(item["views"] is not None for item in album["tracks"])
assert all(item["album"] is not None for item in album["tracks"])
assert album["likeStatus"] is not None
assert album["audioPlaylistId"] is not None
assert album["tracks"][0]["trackNumber"] == 1
assert "feedbackTokens" in album["tracks"][0]
album = yt.get_album("MPREb_BQZvl3BFGay")
assert album["audioPlaylistId"] is not None
assert len(album["tracks"]) == 7
assert len(album["tracks"][0]["artists"]) == 1
album = yt.get_album("MPREb_rqH94Zr3NN0")
assert album["likeStatus"] is not None
assert album["audioPlaylistId"] is not None
assert len(album["tracks"][0]["artists"]) == 2
album = yt.get_album("MPREb_TPH4WqN5pUo") # album with tracks completely removed/missing
assert album["likeStatus"] is not None
assert album["audioPlaylistId"] is not None
assert album["tracks"][0]["trackNumber"] == 3
assert album["tracks"][13]["trackNumber"] == 18
album = yt.get_album("MPREb_YuigcYm2erf") # album with track (#8) disabled/greyed out
assert album["likeStatus"] is not None
assert album["audioPlaylistId"] is not None
assert album["tracks"][7]["trackNumber"] is None

def test_get_album_errors(self, yt):
Expand Down
4 changes: 3 additions & 1 deletion tests/mixins/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def test_get_saved_episodes(self, yt_brand, yt_empty):
def test_get_history(self, yt_oauth):
songs = yt_oauth.get_history()
assert len(songs) > 0
assert all(song["feedbackToken"] is not None for song in songs)

def test_manipulate_history_items(self, yt_auth, sample_video):
song = yt_auth.get_song(sample_video)
Expand All @@ -113,9 +114,10 @@ def test_rate_song(self, yt_auth, sample_video):
response = yt_auth.rate_song(sample_video, "notexist")
assert not response

@pytest.mark.skip(reason="edit_song_library_status is currently broken due to server-side update")
def test_edit_song_library_status(self, yt_brand, sample_album):
album = yt_brand.get_album(sample_album)
response = yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["add"])
response = yt_brand.rate_playlist(album["tracks"][0]["feedbackTokens"]["add"])
album = yt_brand.get_album(sample_album)
assert album["tracks"][0]["inLibrary"]
assert response["feedbackResponses"][0]["isProcessed"]
Expand Down
28 changes: 15 additions & 13 deletions tests/mixins/test_playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,28 @@

class TestPlaylists:
@pytest.mark.parametrize(
("test_file", "owned"),
"test_file",
[
("2024_03_get_playlist.json", True),
("2024_03_get_playlist_public.json", False),
("2024_07_get_playlist_collaborative.json", True),
"2024_03_get_playlist.json",
"2024_03_get_playlist_public.json",
"2024_07_get_playlist_collaborative.json",
],
)
def test_get_playlist_2024(self, yt, test_file, owned):
with open(Path(__file__).parent.parent / "data" / test_file, encoding="utf8") as f:
def test_get_playlist(self, yt, test_file):
data_dir = Path(__file__).parent.parent / "data"
with open(data_dir / test_file, encoding="utf8") as f:
mock_response = json.load(f)
with open(data_dir / "expected_output" / test_file, encoding="utf8") as f:
expected_output = json.load(f)

with mock.patch("ytmusicapi.YTMusic._send_request", return_value=mock_response):
playlist = yt.get_playlist("MPREabc")
assert playlist["year"] == "2024"
assert playlist["owned"] == owned
assert "hours" in playlist["duration"] or "minutes" in playlist["duration"]
assert playlist["id"]
assert isinstance(playlist["description"], str) and playlist["description"]
assert len(playlist["tracks"]) > 0
assert playlist == playlist | expected_output

for thumbnail in playlist.get("thumbnails", []):
assert thumbnail["url"] and thumbnail["width"] and thumbnail["height"]

assert len(playlist["tracks"]) > 0
for track in playlist["tracks"]:
assert isinstance(track["title"], str) and track["title"]

Expand All @@ -48,7 +51,6 @@ def test_get_playlist_2024(self, yt, test_file, owned):
("RDCLAK5uy_nfjzC9YC1NVPPZHvdoAtKVBOILMDOuxOs", 200, 10),
("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", 200, 0), # no related tracks
("PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u", 1000, 10), # very large
("PLZ6Ih9wLHQ2Hm2d3Cb0iV48Z2hQjGRyNz", 300, 10), # runs in subtitle, not title
("PL5ZNf-B8WWSZFIvpJWRjgt7iRqWT7_KF1", 10, 10), # track duration > 1k hours
],
)
Expand Down
29 changes: 29 additions & 0 deletions tests/mixins/test_search.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest

from ytmusicapi import YTMusic
from ytmusicapi.exceptions import YTMusicUserError
from ytmusicapi.parsers.search import ALL_RESULT_TYPES


Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions tests/test.example.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ headers_empty = headers_account_with_empty_library_as_json_as_string
browser_file = ./browser.json
oauth_file = ./oauth.json
headers_raw = raw_headers_pasted_from_browser
client_id = yt-data-api-client-id-tv
client_secret = yt-data-api-client-secret-tv

[queries]
uploads_songs = query_gives_gt_20_songs
Expand Down
2 changes: 1 addition & 1 deletion ytmusicapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
__copyright__ = "Copyright 2023 sigma67"
__license__ = "MIT"
__title__ = "ytmusicapi"
__all__ = ["YTMusic", "setup_oauth", "setup"]
__all__ = ["YTMusic", "setup", "setup_oauth"]
20 changes: 9 additions & 11 deletions ytmusicapi/auth/oauth/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import requests

from ytmusicapi.constants import (
OAUTH_CLIENT_ID,
OAUTH_CLIENT_SECRET,
OAUTH_CODE_URL,
OAUTH_SCOPE,
OAUTH_TOKEN_URL,
Expand Down Expand Up @@ -47,15 +45,15 @@ class OAuthCredentials(Credentials):

def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
client_id: str,
client_secret: str,
session: Optional[requests.Session] = None,
proxies: Optional[dict] = None,
):
"""
:param client_id: Optional. Set the GoogleAPI `client_id` used for auth flows.
Requires `client_secret` also be provided if set.
:param client_secret: Optional. Corresponding secret for provided `client_id`.
:param client_id: Optional. Set the GoogleAPI ``client_id`` used for auth flows.
Requires ``client_secret`` also be provided if set.
:param client_secret: Optional. Corresponding secret for provided ``client_id``.
:param session: Optional. Connection pooling with an active session.
:param proxies: Optional. Modify the session with proxy parameters.
"""
Expand All @@ -66,8 +64,8 @@ def __init__(
)

# bind instance to OAuth client for auth flows
self.client_id = client_id if client_id else OAUTH_CLIENT_ID
self.client_secret = client_secret if client_secret else OAUTH_CLIENT_SECRET
self.client_id = client_id
self.client_secret = client_secret

self._session = session if session else requests.Session() # for auth requests
if proxies:
Expand Down Expand Up @@ -114,10 +112,10 @@ def token_from_code(self, device_code: str) -> RefreshableTokenDict:

def refresh_token(self, refresh_token: str) -> BaseTokenDict:
"""
Method for requesting a new access token for a given `refresh_token`.
Method for requesting a new access token for a given ``refresh_token``.
Token must have been created by the same OAuth client.
:param refresh_token: Corresponding `refresh_token` for a matching `access_token`.
:param refresh_token: Corresponding ``refresh_token`` for a matching ``access_token``.
Obtained via
"""
response = self._send_request(
Expand Down
4 changes: 2 additions & 2 deletions ytmusicapi/auth/oauth/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ def prompt_for_token(
Method for CLI token creation via user inputs.
:param credentials: Client credentials
:param open_browser: Optional. Open browser to OAuth consent url automatically. (Default: `False`).
:param to_file: Optional. Path to store/sync json version of resulting token. (Default: `None`).
:param open_browser: Optional. Open browser to OAuth consent url automatically. (Default: ``False``).
:param to_file: Optional. Path to store/sync json version of resulting token. (Default: ``None``).
"""

code = credentials.get_code()
Expand Down
2 changes: 0 additions & 2 deletions ytmusicapi/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
"TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN", "YE", "ZA", "ZW"
}
# fmt: on
OAUTH_CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com"
OAUTH_CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT"
OAUTH_SCOPE = "https://www.googleapis.com/auth/youtube"
OAUTH_CODE_URL = "https://www.youtube.com/o/oauth2/device/code"
OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"
Expand Down
Loading

0 comments on commit 92cbcfa

Please sign in to comment.