diff --git a/examples/advanced.py b/examples/advanced.py index 8945cfe..2ae9a03 100644 --- a/examples/advanced.py +++ b/examples/advanced.py @@ -345,9 +345,6 @@ class Music(commands.Cog): await player.set_volume(vol) await ctx.send(f'Set the volume to **{vol}**%', delete_after=7) - - - async def setup(bot: commands.Bot): await bot.add_cog(Music(bot)) diff --git a/pomice/applemusic/client.py b/pomice/applemusic/client.py index 118059b..7fbcf6d 100644 --- a/pomice/applemusic/client.py +++ b/pomice/applemusic/client.py @@ -1,12 +1,16 @@ import re import aiohttp -import json +import orjson as json +import base64 +from datetime import datetime from .objects import * +from .exceptions import * AM_URL_REGEX = re.compile(r"https?://music.apple.com/(?P[a-zA-Z]{2})/(?Palbum|playlist|song|artist)/(?P.+)/(?P[^?]+)") AM_SINGLE_IN_ALBUM_REGEX = re.compile(r"https?://music.apple.com/(?P[a-zA-Z]{2})/(?Palbum|playlist|song|artist)/(?P.+)/(?P.+)(\?i=)(?P.+)") AM_REQ_URL = "https://api.music.apple.com/v1/catalog/{country}/{type}s/{id}" +AM_BASE_URL = "https://api.music.apple.com" class Client: """The base Apple Music client for Pomice. @@ -16,14 +20,17 @@ class Client: def __init__(self) -> None: self.token: str = None - self.origin: str = None - self.session: aiohttp.ClientSession = None + self.expiry: datetime = None + self.session: aiohttp.ClientSession = aiohttp.ClientSession() self.headers = None async def request_token(self): - self.session = aiohttp.ClientSession() async with self.session.get("https://music.apple.com/assets/index.919fe17f.js") as resp: + if resp.status != 200: + raise AppleMusicRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) text = await resp.text() result = re.search("\"(eyJ.+?)\"", text).group(1) self.token = result @@ -31,10 +38,14 @@ class Client: 'Authorization': f"Bearer {result}", 'Origin': 'https://apple.com', } - + token_split = self.token.split(".")[1] + token_json = base64.b64decode(token_split + '=' * (-len(token_split) % 4)).decode() + token_data = json.loads(token_json) + self.expiry = datetime.fromtimestamp(token_data["exp"]) + async def search(self, query: str): - if not self.token: + if not self.token or datetime.utcnow() > self.expiry: await self.request_token() result = AM_URL_REGEX.match(query) @@ -47,39 +58,66 @@ class Client: # apple music likes to generate links for singles off an album # by adding a param at the end of the url # so we're gonna scan for that and correct it - id = sia_result.group("id2") type = "song" request_url = AM_REQ_URL.format(country=country, type=type, id=id) else: request_url = AM_REQ_URL.format(country=country, type=type, id=id) - print(request_url) - - print(self.token) async with self.session.get(request_url, headers=self.headers) as resp: - print(resp.status) - data = await resp.json() + if resp.status != 200: + raise AppleMusicRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + data: dict = await resp.json(loads=json.loads) - with open('yes.txt', 'w') as file: - file.write(json.dumps(data)) - - if type == "playlist": - return Playlist(data) + data = data["data"][0] + + + if type == "song": + return Song(data) elif type == "album": return Album(data) - elif type == "song": - return Song(data) - elif type == "artist": - return Artist(data) + async with self.session.get(f"{request_url}/view/top-songs", headers=self.headers) as resp: + if resp.status != 200: + raise AppleMusicRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + top_tracks: dict = await resp.json(loads=json.loads) + tracks: dict = top_tracks["data"] + + return Artist(data, tracks=tracks) + + else: + tracks = [Song(track) for track in data["relationships"]["tracks"]["data"]] + + if not len(tracks): + raise AppleMusicRequestException("This playlist is empty and therefore cannot be queued.") + + if data["relationships"]["tracks"]["next"]: + next_page_url = AM_BASE_URL + data["relationships"]["tracks"]["next"] + + while next_page_url is not None: + async with self.session.get(next_page_url, headers=self.headers) as resp: + if resp.status != 200: + raise AppleMusicRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + + next_data: dict = await resp.json(loads=json.loads) + + tracks += [Song(track) for track in next_data["data"]] + if next_data.get("next"): + next_page_url = AM_BASE_URL + next_data["next"] + else: + next_page_url = None + + return Playlist(data, tracks) + - - - - - await self.session.close() \ No newline at end of file + \ No newline at end of file diff --git a/pomice/applemusic/objects.py b/pomice/applemusic/objects.py index 5271b28..070dd48 100644 --- a/pomice/applemusic/objects.py +++ b/pomice/applemusic/objects.py @@ -1,24 +1,41 @@ +"""Module for managing Apple Music objects""" + +from typing import List + + class Song: - def __init__(self, data: dict) -> None: - self.track_data = ["data"][0] - self.name = self.track_data["attributes"]["name"] - self.url = self.track_data["atrributes"]["url"] - self.isrc = self.track_data["atrributes"]["isrc"] - self.length = self.track_data["atrributes"]["durationInMillis"] - self.id = self.track_data["id"] - self.artists = self.track_data["atrributes"]["artistName"] - self.image = self.track_data["atrributes"]["artwork"]["url"].replace("{w}x{h}", f'{self.track_data["atrributes"]["artwork"]["width"]}x{self.track_data["atrributes"]["artwork"]["height"]}') + """The base class for an Apple Music song""" + def __init__(self, data: dict) -> None: + self.name: str = data["attributes"]["name"] + self.url: str = data["attributes"]["url"] + self.isrc: str = data["attributes"]["isrc"] + self.length: float = data["attributes"]["durationInMillis"] + self.id: str = data["id"] + self.artists: str = data["attributes"]["artistName"] + self.image: str = data["attributes"]["artwork"]["url"].replace( + "{w}x{h}", + f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}' + ) def __repr__(self) -> str: return ( - f"" ) class Playlist: - def __init__(self, data: dict) -> None: - pass + """The base class for an Apple Music playlist""" + def __init__(self, data: dict, tracks: List[Song]) -> None: + self.name: str = data["attributes"]["name"] + self.owner: str = data["attributes"]["curatorName"] + self.id: str = data["id"] + self.tracks: List[Song] = tracks + self.total_tracks: int = len(tracks) + self.url: str = data["attributes"]["url"] + # we'll use the first song's image as the image for the playlist + # because apple dynamically generates playlist covers client-side + self.image = self.tracks[0].image def __repr__(self) -> str: return ( @@ -28,8 +45,18 @@ class Playlist: class Album: + """The base class for an Apple Music album""" def __init__(self, data: dict) -> None: - pass + self.name: str = data["attributes"]["name"] + self.url: str = data["attributes"]["url"] + self.id: str = data["id"] + self.artists: str = data["attributes"]["artistName"] + self.total_tracks: int = data["attributes"]["trackCount"] + self.tracks: List[Song] = [Song(track) for track in data["relationships"]["tracks"]["data"]] + self.image: str = data["attributes"]["artwork"]["url"].replace( + "{w}x{h}", + f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}' + ) def __repr__(self) -> str: return ( @@ -40,8 +67,17 @@ class Album: class Artist: - def __init__(self, data: dict) -> None: - pass + """The base class for an Apple Music artist""" + def __init__(self, data: dict, tracks: dict) -> None: + self.name: str = f'Top tracks for {data["attributes"]["name"]}' + self.url: str = data["attributes"]["url"] + self.id: str = data["id"] + self.genres: str = ", ".join(genre for genre in data["attributes"]["genreNames"]) + self.tracks: List[Song] = [Song(track) for track in tracks] + self.image: str = data["attributes"]["artwork"]["url"].replace( + "{w}x{h}", + f'{data["attributes"]["artwork"]["width"]}x{data["attributes"]["artwork"]["height"]}' + ) def __repr__(self) -> str: return ( diff --git a/pomice/objects.py b/pomice/objects.py index 191a282..d0b90b1 100644 --- a/pomice/objects.py +++ b/pomice/objects.py @@ -7,6 +7,11 @@ from discord.ext import commands from .enums import SearchType from .filters import Filter +from . import ( + spotify, + applemusic +) + SOUNDCLOUD_URL_REGEX = re.compile( r"^(https?:\/\/)?(www.)?(m\.)?soundcloud\.com\/[\w\-\.]+(\/)+[\w\-\.]+/?$" ) @@ -24,8 +29,10 @@ class Track: info: dict, ctx: Optional[commands.Context] = None, spotify: bool = False, + apple_music: bool = False, + am_track: applemusic.Song = None, search_type: SearchType = SearchType.ytsearch, - spotify_track = None, + spotify_track: spotify.Track = None, filters: Optional[List[Filter]] = None, timestamp: Optional[float] = None, requester: Optional[Union[Member, User]] = None @@ -33,12 +40,17 @@ class Track: self.track_id = track_id self.info = info self.spotify = spotify + self.apple_music = apple_music self.filters: List[Filter] = filters self.timestamp: Optional[float] = timestamp - self.original: Optional[Track] = None if spotify else self + if spotify or apple_music: + self.original: Optional[Track] = None + else: + self.original = self self._search_type = search_type self.spotify_track = spotify_track + self.am_track = am_track self.title = info.get("title") self.author = info.get("author") @@ -95,13 +107,17 @@ class Playlist: tracks: list, ctx: Optional[commands.Context] = None, spotify: bool = False, - spotify_playlist = None + spotify_playlist: spotify.Playlist = None, + apple_music: bool = False, + am_playlist: applemusic.Playlist = None ): self.playlist_info = playlist_info self.tracks_raw = tracks self.spotify = spotify self.name = playlist_info.get("name") self.spotify_playlist = spotify_playlist + self.apple_music = apple_music + self.am_playlist = am_playlist self._thumbnail = None self._uri = None @@ -110,6 +126,12 @@ class Playlist: self.tracks = tracks self._thumbnail = self.spotify_playlist.image self._uri = self.spotify_playlist.uri + + elif self.apple_music: + self.tracks = tracks + self._thumbnail = self.am_playlist.image + self._uri = self.am_playlist.url + else: self.tracks = [ Track(track_id=track["track"], info=track["info"], ctx=ctx) @@ -133,10 +155,10 @@ class Playlist: @property def uri(self) -> Optional[str]: - """Spotify album/playlist URI, or None if not a Spotify object.""" + """Returns either an Apple Music/Spotify URL/URI, or None if its neither of those.""" return self._uri @property def thumbnail(self) -> Optional[str]: - """Spotify album/playlist thumbnail, or None if not a Spotify object.""" + """Returns either an Apple Music/Spotify album/playlist thumbnail, or None if its neither of those.""" return self._thumbnail diff --git a/pomice/player.py b/pomice/player.py index c335c24..a71f052 100644 --- a/pomice/player.py +++ b/pomice/player.py @@ -33,6 +33,11 @@ class Filters: """Property which checks if any applied filters were preloaded""" return any(f for f in self._filters if f.preload == True) + @property + def has_global(self): + """Property which checks if any applied filters are global""" + return any(f for f in self._filters if f.preload == False) + @property def empty(self): diff --git a/pomice/pool.py b/pomice/pool.py index 3ed5195..57df7a3 100644 --- a/pomice/pool.py +++ b/pomice/pool.py @@ -380,7 +380,61 @@ class Node: "Please set apple_music to True in your Node class." ) - await self._apple_music_client.search(query=query) + apple_music_results = await self._apple_music_client.search(query=query) + if isinstance(apple_music_results, applemusic.Song): + return [ + Track( + track_id=apple_music_results.id, + ctx=ctx, + search_type=search_type, + apple_music=True, + am_track=apple_music_results, + filters=filters, + info={ + "title": apple_music_results.name, + "author": apple_music_results.artists, + "length": apple_music_results.length, + "identifier": apple_music_results.id, + "uri": apple_music_results.url, + "isStream": False, + "isSeekable": True, + "position": 0, + "thumbnail": apple_music_results.image, + "isrc": apple_music_results.isrc + } + ) + ] + + tracks = [ + Track( + track_id=track.id, + ctx=ctx, + search_type=search_type, + apple_music=True, + am_track=track, + filters=filters, + info={ + "title": track.name, + "author": track.artists, + "length": track.length, + "identifier": track.id, + "uri": track.url, + "isStream": False, + "isSeekable": True, + "position": 0, + "thumbnail": track.image, + "isrc": track.isrc + } + ) for track in apple_music_results.tracks + ] + + return Playlist( + playlist_info={"name": apple_music_results.name, "selectedTrack": 0}, + tracks=tracks, + ctx=ctx, + apple_music=True, + am_playlist=apple_music_results + ) elif SPOTIFY_URL_REGEX.match(query): diff --git a/pomice/spotify/objects.py b/pomice/spotify/objects.py index f46bb69..1cac977 100644 --- a/pomice/spotify/objects.py +++ b/pomice/spotify/objects.py @@ -6,24 +6,24 @@ class Track: def __init__(self, data: dict, image = None) -> None: self.name: str = data["name"] - self.artists = ", ".join(artist["name"] for artist in data["artists"]) + self.artists: str = ", ".join(artist["name"] for artist in data["artists"]) self.length: float = data["duration_ms"] self.id: str = data["id"] if data.get("external_ids"): - self.isrc = data["external_ids"]["isrc"] + self.isrc: str = data["external_ids"]["isrc"] else: self.isrc = None if data.get("album") and data["album"].get("images"): - self.image = data["album"]["images"][0]["url"] + self.image: str = data["album"]["images"][0]["url"] else: - self.image = image + self.image: str = image if data["is_local"]: self.uri = None else: - self.uri = data["external_urls"]["spotify"] + self.uri: str = data["external_urls"]["spotify"] def __repr__(self) -> str: return ( @@ -35,13 +35,13 @@ class Playlist: """The base class for a Spotify playlist""" def __init__(self, data: dict, tracks: List[Track]) -> None: - self.name = data["name"] + self.name: str = data["name"] self.tracks = tracks - self.owner = data["owner"]["display_name"] - self.total_tracks = data["tracks"]["total"] - self.id = data["id"] + self.owner: str = data["owner"]["display_name"] + self.total_tracks: int = data["tracks"]["total"] + self.id: str = data["id"] if data.get("images") and len(data["images"]): - self.image = data["images"][0]["url"] + self.image: str = data["images"][0]["url"] else: self.image = None self.uri = data["external_urls"]["spotify"] @@ -56,13 +56,13 @@ class Album: """The base class for a Spotify album""" def __init__(self, data: dict) -> None: - self.name = data["name"] - self.artists = ", ".join(artist["name"] for artist in data["artists"]) - self.image = data["images"][0]["url"] + self.name: str = data["name"] + self.artists: str = ", ".join(artist["name"] for artist in data["artists"]) + self.image: str = data["images"][0]["url"] self.tracks = [Track(track, image=self.image) for track in data["tracks"]["items"]] - self.total_tracks = data["total_tracks"] - self.id = data["id"] - self.uri = data["external_urls"]["spotify"] + self.total_tracks: int = data["total_tracks"] + self.id: str = data["id"] + self.uri: str = data["external_urls"]["spotify"] def __repr__(self) -> str: return ( @@ -74,13 +74,13 @@ class Artist: """The base class for a Spotify artist""" def __init__(self, data: dict, tracks: dict) -> None: - self.name = f"Top tracks for {data['name']}" # Setting that because its only playing top tracks - self.genres = ", ".join(genre for genre in data["genres"]) - self.followers = data["followers"]["total"] - self.image = data["images"][0]["url"] + self.name: str = f"Top tracks for {data['name']}" # Setting that because its only playing top tracks + self.genres: str = ", ".join(genre for genre in data["genres"]) + self.followers: int = data["followers"]["total"] + self.image: str = data["images"][0]["url"] self.tracks = [Track(track, image=self.image) for track in tracks] - self.id = data["id"] - self.uri = data["external_urls"]["spotify"] + self.id: str = data["id"] + self.uri: str = data["external_urls"]["spotify"] def __repr__(self) -> str: return (