Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into sgvictorino-fix-playl…
Browse files Browse the repository at this point in the history
…ist-title
  • Loading branch information
sigma67 committed Dec 17, 2024
2 parents d6280e6 + fe95f59 commit a05abe8
Show file tree
Hide file tree
Showing 15 changed files with 483 additions and 340 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
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
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
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
1 change: 0 additions & 1 deletion tests/mixins/test_playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ def test_get_playlist(self, yt, test_file, output):
("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
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"]
10 changes: 4 additions & 6 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,8 +45,8 @@ 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,
):
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
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
3 changes: 2 additions & 1 deletion ytmusicapi/mixins/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ def get_history(self) -> list[dict]:
if not data:
error = nav(content, ["musicNotifierShelfRenderer", *TITLE], True)
raise YTMusicServerError(error)
songlist = parse_playlist_items(data)
menu_entries = [[*MENU_SERVICE, *FEEDBACK_TOKEN]]
songlist = parse_playlist_items(data, menu_entries)
for song in songlist:
song["played"] = nav(content["musicShelfRenderer"], TITLE_TEXT)
songs.extend(songlist)
Expand Down
7 changes: 6 additions & 1 deletion ytmusicapi/parsers/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,13 @@ def parse_playlist_item(
song["feedbackTokens"] = feedback_tokens

if menu_entries:
# sets the feedbackToken for get_history
menu_items = nav(data, MENU_ITEMS)
for menu_entry in menu_entries:
song[menu_entry[-1]] = nav(data, MENU_ITEMS + menu_entry)
items = find_objects_by_key(menu_items, menu_entry[0])
song[menu_entry[-1]] = next(
filter(lambda x: x is not None, (nav(itm, menu_entry, True) for itm in items)), None
)

return song

Expand Down
41 changes: 28 additions & 13 deletions ytmusicapi/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,52 +23,67 @@ def setup(filepath: Optional[str] = None, headers_raw: Optional[str] = None) ->


def setup_oauth(
client_id: str,
client_secret: str,
filepath: Optional[str] = None,
session: Optional[requests.Session] = None,
proxies: Optional[dict] = None,
open_browser: bool = False,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
) -> RefreshingToken:
"""
Starts oauth flow from the terminal
and returns a string that can be passed to YTMusic()
:param client_id: Optional. Used to specify the client_id oauth should use for authentication
flow. If provided, client_secret MUST also be passed or both will be ignored.
:param client_secret: Optional. Same as client_id but for the oauth client secret.
:param session: Session to use for authentication
:param proxies: Proxies to use for authentication
:param filepath: Optional filepath to store headers to.
:param open_browser: If True, open the default browser with the setup link
:param client_id: Optional. Used to specify the client_id oauth should use for authentication
flow. If provided, client_secret MUST also be passed or both will be ignored.
:param client_secret: Optional. Same as client_id but for the oauth client secret.
:return: configuration headers string
"""
if not session:
session = requests.Session()

if client_id and client_secret:
oauth_credentials = OAuthCredentials(client_id, client_secret, session, proxies)

else:
oauth_credentials = OAuthCredentials(session=session, proxies=proxies)
oauth_credentials = OAuthCredentials(client_id, client_secret, session, proxies)

return RefreshingToken.prompt_for_token(oauth_credentials, open_browser, filepath)


def parse_args(args):
parser = argparse.ArgumentParser(description="Setup ytmusicapi.")
parser.add_argument("setup_type", type=str, choices=["oauth", "browser"], help="choose a setup type.")
parser.add_argument("--file", type=Path, help="optional path to output file.")
# parser.add_argument("setup_type", type=str, choices=["oauth", "browser"], help="choose a setup type.")
subparsers = parser.add_subparsers(help="choose a setup type.", dest="setup_type")
oauth_parser = subparsers.add_parser(
"oauth",
help="create an oauth token using your Google Youtube API credentials; type 'ytmusicapi oauth -h' for details.",
)
oauth_parser.add_argument("--file", type=Path, help="optional path to output file")
oauth_parser.add_argument("--client-id", type=str, help="use your Google Youtube API client ID.")
oauth_parser.add_argument("--client-secret", type=str, help="use your Google Youtube API client secret.")
browser_parser = subparsers.add_parser(
"browser",
help="use cookies from request headers (deprecated); type 'ytmusicapi browser -h' for details.",
)
browser_parser.add_argument("--file", type=Path, help="optional path to output file.")

return parser.parse_args(args)


def main():
args = parse_args(sys.argv[1:])
if args.setup_type == "oauth" and (args.client_id is None or args.client_secret is None):
print(
"You have to supply both your Google Youtube API client ID and client secret to create a valid oauth token."
)
return
filename = args.file.as_posix() if args.file else f"{args.setup_type}.json"
print(f"Creating {filename} with your authentication credentials...")
if args.setup_type == "oauth":
return setup_oauth(filename, open_browser=True)
return setup_oauth(
client_id=args.client_id, client_secret=args.client_secret, filepath=filename, open_browser=True
)
else:
return setup(filename)
8 changes: 3 additions & 5 deletions ytmusicapi/ytmusic.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def __init__(
self.auth_type: AuthType = AuthType.UNAUTHORIZED

self._token: Token #: OAuth credential handler
self.oauth_credentials: OAuthCredentials #: Client used for OAuth refreshing
self.oauth_credentials: Optional[OAuthCredentials] #: Client used for OAuth refreshing

self._session: requests.Session #: request session for connection pooling
self.proxies: Optional[dict[str, str]] = proxies #: params for session modification
Expand All @@ -118,9 +118,7 @@ def __init__(
# value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502
self.cookies = {"SOCS": "CAI"}
if self.auth is not None:
self.oauth_credentials = (
oauth_credentials if oauth_credentials is not None else OAuthCredentials()
)
self.oauth_credentials = oauth_credentials
auth_path: Optional[Path] = None
if isinstance(self.auth, str):
auth_str: str = self.auth
Expand All @@ -134,7 +132,7 @@ def __init__(
else:
self._input_dict = CaseInsensitiveDict(self.auth)

if OAuthToken.is_oauth(self._input_dict):
if self.oauth_credentials is not None and OAuthToken.is_oauth(self._input_dict):
self._token = RefreshingToken(
credentials=self.oauth_credentials, _local_cache=auth_path, **self._input_dict
)
Expand Down

0 comments on commit a05abe8

Please sign in to comment.