Source code for gd.song

from pathlib import Path

from attr import attrib, dataclass
from iters import iter

from gd.abstract_entity import AbstractEntity
from gd.async_iters import awaitable_iterator
from gd.errors import MissingAccess
from gd.http import NEWGROUNDS_SONG_LISTEN, URL
from gd.model import SongModel  # type: ignore
from gd.text_utils import make_repr
from gd.typing import IO, TYPE_CHECKING, Any, AsyncIterator, Dict, Iterable, Optional, Union

if TYPE_CHECKING:
    from gd.client import Client  # noqa

__all__ = (
    "ArtistInfo",
    "Author",
    "Song",
    "default_song",
    "official_client_songs",
    "official_server_songs",
)


[docs]class Song(AbstractEntity): """Class that represents Geometry Dash/Newgrounds songs. This class is derived from :class:`~gd.AbstractEntity`. """ def __repr__(self) -> str: info = {"id": self.id, "name": repr(self.name), "author": repr(self.author)} return make_repr(self, info) def __str__(self) -> str: return str(self.name)
[docs] @classmethod def from_model( # type: ignore cls, model: SongModel, *, client: Optional["Client"] = None, custom: bool = True ) -> "Song": return cls( id=model.id, name=model.name, size=model.size, author=model.author, download_link=model.download_link, custom=custom, client=client, )
@property def name(self) -> int: """:class:`str`: A name of the song.""" return self.options.get("name", "") @property def size(self) -> float: """:class:`float`: A float representing size of the song, in megabytes.""" return self.options.get("size", 0.0) @property def author(self) -> str: """:class:`str`: An author of the song.""" return self.options.get("author", "") @property def link(self) -> str: """:class:`str`: A link to the song on Newgrounds, e.g. ``.../audio/listen/<id>``.""" if self.is_custom(): return NEWGROUNDS_SONG_LISTEN.format(song_id=self.id) return "" @property def download_link(self) -> str: """:class:`str`: A link to download the song, used in :meth:`.Song.download`.""" return self.options.get("download_link", "")
[docs] def is_custom(self) -> bool: """:class:`bool`: Indicates whether the song is custom or not.""" return bool(self.options.get("custom", True))
@classmethod def official( cls, id: Optional[int] = None, name: Optional[str] = None, index: Optional[int] = None, server_style: bool = True, return_default: bool = True, *, client: Optional["Client"] = None, ) -> "Song": official_songs = official_server_songs if server_style else official_client_songs if id is not None: official_song = iter(official_songs).get(id=id) elif name is not None: official_song = iter(official_songs).get(name=name) elif index is not None: try: official_song = official_songs[index] except (IndexError, ValueError, TypeError): official_song = None else: raise ValueError("Expected either of queries: id, name or index.") if official_song is None: if return_default: official_song = get_default_song(id) else: raise LookupError("Could not find official level by given query.") return cls( id=official_song.id, name=official_song.name, size=0.0, author=official_song.author, custom=False, client=client, )
[docs] def get_author(self) -> "Author": """:class:`~gd.Author`: Author of the song.""" if not self.is_custom(): raise MissingAccess("Can not get author of an official song.") return Author(name=self.author, client=self.client)
[docs] async def update(self, from_ng: bool = False) -> None: """Update the song. Parameters ---------- from_ng: :class:`bool` Whether to fetch song from Newgrounds. Raises ------ :exc:`~gd.MissingAccess` Failed to find the song. :exc:`~gd.HTTPStatusError` Server returned error status code. :exc:`~gd.HTTPError` Failed to process the request. """ if from_ng: new = await self.client.get_ng_song(self.id) else: new = await self.client.get_song(self.id) self.options.update(new.options)
[docs] async def get_artist_info(self) -> "ArtistInfo": """Fetch artist info of ``self``. Acts like the following: .. code-block:: python3 await client.get_artist_info(song.id) Raises ------ :exc:`~gd.MissingAccess` Failed to find artist info. :exc:`~gd.HTTPStatusError` Server returned error status code. :exc:`~gd.HTTPError` Failed to process the request. Returns ------- :class:`~gd.ArtistInfo` Fetched info about an artist. """ if not self.is_custom(): # pragma: no cover return ArtistInfo( id=self.id, artist=self.author, song=self.name, whitelisted=True, scouted=True, api=True, custom=False, client=self.client_unchecked, ) return await self.client.get_artist_info(self.id)
[docs] async def download( self, file: Optional[Union[str, Path, IO]] = None, with_bar: bool = False, ) -> Optional[bytes]: """Download a song from Newgrounds. Parameters ---------- file: Optional[Union[:class:`str`, :class:`~pathlib.Path`, IO]] File-like or Path-like object to write song to, instead of returning bytes. with_bar: :class:`bool` Whether to show a progress bar while downloading. Requires ``tqdm`` to be installed. Raises ------ :exc:`~gd.MissingAccess` Can not download the song because it is official or not found. :exc:`~gd.HTTPStatusError` Server returned error status code. :exc:`~gd.HTTPError` Failed to process the request. Returns ------- Optional[:class:`bytes`] A song as bytes, if ``file`` was not specified. """ if not self.is_custom(): raise MissingAccess("Song is official. Can not download.") if not self.download_link: # load song from newgrounds if there is no link await self.update(from_ng=True) return await self.client.http.download(self.download_link, file=file, with_bar=with_bar)
[docs]class ArtistInfo(AbstractEntity): """Class that represents info about the creator of a particular song.""" def __str__(self) -> str: return str(self.artist) def __repr__(self) -> str: info = { "id": self.id, "artist": repr(self.artist), "song": repr(self.song), "is_scouted": self.is_scouted(), "is_whitelisted": self.is_whitelisted(), "exists": self.exists, } return make_repr(self, info)
[docs] def to_dict(self) -> Dict[str, Any]: result = super().to_dict() result.update(exists=self.exists) return result
@property def artist(self) -> str: """:class:`str`: Author of the song.""" return self.options.get("artist", "") @property def song(self) -> str: """:class:`str`: A name of the song.""" return self.options.get("song", "") @property def exists(self) -> bool: """:class:`bool`: Whether the song exists.""" return bool(self.artist and self.song)
[docs] def is_scouted(self) -> bool: """:class:`bool`: Whether the artist is scouted.""" return bool(self.options.get("scouted"))
[docs] def is_whitelisted(self) -> bool: """:class:`bool`: Whether the artist is whitelisted.""" return bool(self.options.get("whitelisted"))
[docs] def api_allowed(self) -> bool: """:class:`bool`: Whether the external API is allowed.""" return bool(self.options.get("api"))
[docs] def is_custom(self) -> bool: """:class:`bool`: Whether the song is custom.""" return bool(self.options.get("custom", True))
[docs] def get_author(self) -> "Author": """:class:`~gd.Author` of the song.""" if not self.is_custom(): raise MissingAccess("Can not get author of an official song.") return Author(name=self.artist, client=self.client)
author = property(get_author)
[docs] async def update(self) -> None: """Update artist info. Raises ------ :exc:`~gd.MissingAccess` Failed to find artist info. :exc:`~gd.HTTPStatusError` Server returned error status code. :exc:`~gd.HTTPError` Failed to process the request. """ new = await self.client.get_artist_info(self.id) self.options.update(new.options)
[docs]class Author(AbstractEntity): """Class that represents an author on Newgrounds. This class is derived from :class:`~gd.AbstractEntity`. """ def __repr__(self) -> str: info = {"name": repr(self.name), "link": repr(self.link)} return make_repr(self, info) def __str__(self) -> str: return str(self.name) @property def id(self) -> int: """:class:`int`: ID of the Author.""" return hash(self.name) | hash(self.link) @property def link(self) -> URL: """:class:`~yarl.URL`: URL to author's page.""" return URL(self.options.get("link", f"https://{self.name}.newgrounds.com/")) @property def name(self) -> str: """:class:`str`: Name of the author.""" return self.options.get("name", "")
[docs] @awaitable_iterator def get_page_songs(self, page: int = 0) -> AsyncIterator[Song]: """Get songs on the page. Parameters ---------- page: :class:`int` Page of songs to look at. Raises ------ :exc:`~gd.MissingAccess` Failed to find songs. :exc:`~gd.HTTPStatusError` Server returned error status code. :exc:`~gd.HTTPError` Failed to process the request. Returns ------- AsyncIterator[:class:`~gd.Song`] Songs found. """ return self.client.get_ng_user_songs_on_page(self, page=page)
[docs] @awaitable_iterator def get_songs(self, pages: Iterable[int] = range(10)) -> AsyncIterator[Song]: """Get songs on the pages. Parameters ---------- pages: Iterable[:class:`int`] Pages of songs to look at. Raises ------ :exc:`~gd.MissingAccess` Failed to find songs. :exc:`~gd.HTTPStatusError` Server returned error status code. :exc:`~gd.HTTPError` Failed to process the request. Returns ------- AsyncIterator[:class:`~gd.Song`] Songs found. """ return self.client.get_ng_user_songs(self, pages=pages)
@dataclass class OfficialSong: id: int = attrib() author: str = attrib() name: str = attrib() default_song = OfficialSong(id=0, author="DJVI", name="Unknown") def get_default_song(id: Optional[int] = None) -> OfficialSong: if id is None: return default_song return OfficialSong(id=id, author=default_song.author, name=default_song.name) official_client_songs = ( OfficialSong(id=0, author="OcularNebula", name="Practice: Stay Inside Me"), OfficialSong(id=1, author="ForeverBound", name="Stereo Madness"), OfficialSong(id=2, author="DJVI", name="Back On Track"), OfficialSong(id=3, author="Step", name="Polargeist"), OfficialSong(id=4, author="DJVI", name="Dry Out"), OfficialSong(id=5, author="DJVI", name="Base After Base"), OfficialSong(id=6, author="DJVI", name="Cant Let Go"), OfficialSong(id=7, author="Waterflame", name="Jumper"), OfficialSong(id=8, author="Waterflame", name="Time Machine"), OfficialSong(id=9, author="DJVI", name="Cycles"), OfficialSong(id=10, author="DJVI", name="xStep"), OfficialSong(id=11, author="Waterflame", name="Clutterfunk"), OfficialSong(id=12, author="DJ-Nate", name="Theory of Everything"), OfficialSong(id=13, author="Waterflame", name="Electroman Adventures"), OfficialSong(id=14, author="DJ-Nate", name="Clubstep"), OfficialSong(id=15, author="DJ-Nate", name="Electrodynamix"), OfficialSong(id=16, author="Waterflame", name="Hexagon Force"), OfficialSong(id=17, author="Waterflame", name="Blast Processing"), OfficialSong(id=18, author="DJ-Nate", name="Theory of Everything 2"), OfficialSong(id=19, author="Waterflame", name="Geometrical Dominator"), OfficialSong(id=20, author="F-777", name="Deadlocked"), OfficialSong(id=21, author="MDK", name="Fingerdash"), OfficialSong(id=22, author="F-777", name="The Seven Seas"), OfficialSong(id=23, author="F-777", name="Viking Arena"), OfficialSong(id=24, author="F-777", name="Airborne Robots"), OfficialSong(id=25, author="RobTop", name="Secret"), # aka DJRubRub, LOL OfficialSong(id=26, author="Dex Arson", name="Payload"), OfficialSong(id=27, author="Dex Arson", name="Beast Mode"), OfficialSong(id=28, author="Dex Arson", name="Machina"), OfficialSong(id=29, author="Dex Arson", name="Years"), OfficialSong(id=30, author="Dex Arson", name="Frontlines"), OfficialSong(id=31, author="Waterflame", name="Space Pirates"), OfficialSong(id=32, author="Waterflame", name="Striker"), OfficialSong(id=33, author="Dex Arson", name="Embers"), OfficialSong(id=34, author="Dex Arson", name="Round 1"), OfficialSong(id=35, author="F-777", name="Monster Dance Off"), OfficialSong(id=36, author="MDK", name="Press Start"), OfficialSong(id=37, author="Bossfight", name="Nock Em"), OfficialSong(id=38, author="Boom Kitty", name="Power Trip"), ) official_server_songs = tuple( OfficialSong(id=official_song.id - 1, author=official_song.author, name=official_song.name) for official_song in official_client_songs )