Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Add support for creating poster by spotify album/track url only #32

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions BeatPrints/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,37 @@ def __init__(
):
self.message = message
super().__init__(self.message)


class MissingParameterError(Exception):
"""
Raised when a required parameter is missing.
"""

def __init__(self, param_name=None):
self.param_name = param_name
message = (
f"Required parameter '{param_name}' is missing"
if param_name
else "A required parameter is missing"
)
self.message = message
super().__init__(self.message)

class InvalidTrackUrlError(Exception):
"""
Raised when the track URL is invalid.
"""

def __init__(self, message="The track URL is invalid."):
self.message = message
super().__init__(self.message)

class InvalidAlbumUrlError(Exception):
"""
Raised when the album URL is invalid.
"""

def __init__(self, message="The album URL is invalid."):
self.message = message
super().__init__(self.message)
132 changes: 113 additions & 19 deletions BeatPrints/spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
import random
import requests
import datetime
import re

from typing import List
from typing import List, Optional
from dataclasses import dataclass

from .errors import NoMatchingTrackFound, NoMatchingAlbumFound, InvalidSearchLimit
from .errors import (InvalidAlbumUrlError, InvalidSearchLimit,
InvalidTrackUrlError, MissingParameterError,
NoMatchingAlbumFound, NoMatchingTrackFound)


@dataclass
Expand Down Expand Up @@ -45,6 +48,10 @@ class AlbumMetadata:
tracks: List[str]


TYPE_TRACK = "track"
TYPE_ALBUM = "album"


class Spotify:
"""
A class for interacting with the Spotify API to search and retrieve track/album information.
Expand Down Expand Up @@ -117,12 +124,37 @@ def _format_duration(self, duration_ms: int) -> str:
seconds = (duration_ms // 1000) % 60
return f"{minutes:02d}:{seconds:02d}"

def get_track(self, query: str, limit: int = 6) -> List[TrackMetadata]:
def parse_url(self, url: str) -> tuple:
"""
Parses a Spotify URL to extract the type and ID.

Args:
url (str): The Spotify URL to be parsed.

Returns:
tuple: A tuple containing two elements:
- The type of the Spotify URL (e.g., "track", "album").
- The ID of the Spotify URL.
"""

# Extract the type and ID from the Spotify URL
match = re.match(r"https://open.spotify.com/(track|album)/(\w+)", url)

if match:
return match.groups()

return None, None

def get_track(
self, query: Optional[str] = None, url: Optional[str] = None, limit: int = 6
) -> List[TrackMetadata]:
"""
Searches for tracks based on a query and retrieves their metadata.
Searches for tracks using a query to retrieve metadata.
Alternatively, fetches track details directly from a Spotify album URL.

Args:
query (str): The search query for the track (e.g. track name - artist).
url (str): The Spotify track URL.
limit (int, optional): Maximum number of tracks to retrieve. Defaults to 6.

Returns:
Expand All @@ -131,17 +163,26 @@ def get_track(self, query: str, limit: int = 6) -> List[TrackMetadata]:
Raises:
InvalidSearchLimit: If the limit is less than 1.
NoMatchingTrackFound: If no matching tracks are found.
MissingParameterError: If no query or URL is provided.
"""
if limit < 1:
raise InvalidSearchLimit

if not query and not url:
raise MissingParameterError("query or url")

tracklist = []
params = {"q": query, "type": "track", "limit": limit}
response = requests.get(
f"{self.__BASE_URL}/search", params=params, headers=self.__AUTH_HEADER
).json()

tracks = response.get("tracks", {}).get("items", [])
if url:
response, _ = self.get_from_url(url, TYPE_TRACK)
tracks = [response]
else:
params = {"q": query, "type": TYPE_TRACK, "limit": limit}
response = requests.get(
f"{self.__BASE_URL}/search", params=params, headers=self.__AUTH_HEADER
).json()

tracks = response.get("tracks", {}).get("items", [])

if not tracks:
raise NoMatchingTrackFound
Expand Down Expand Up @@ -182,13 +223,19 @@ def get_track(self, query: str, limit: int = 6) -> List[TrackMetadata]:
return tracklist

def get_album(
self, query: str, limit: int = 6, shuffle: bool = False
self,
query: Optional[str] = None,
url: Optional[str] = None,
limit: int = 6,
shuffle: bool = False,
) -> List[AlbumMetadata]:
"""
Searches for albums based on a query and retrieves their metadata, including track listing.
Searches for albums using a query parameter to retrieve metadata, including track listings.
Alternatively, fetches album details directly from a Spotify album URL.

Args:
query (str): The search query for the album (e.g. album name - artist).
url (str): The Spotify album URL.
limit (int, optional): Maximum number of albums to retrieve. Defaults to 6.
shuffle (bool, optional): Shuffle the tracks in the tracklist. Defaults to False.

Expand All @@ -202,23 +249,37 @@ def get_album(
if limit < 1:
raise InvalidSearchLimit

if not query and not url:
raise MissingParameterError("query or url")

albumlist = []
params = {"q": query, "type": "album", "limit": limit}
response = requests.get(
f"{self.__BASE_URL}/search", params=params, headers=self.__AUTH_HEADER
).json()

albums = response.get("albums", {}).get("items", [])
if url:
response, _ = self.get_from_url(url, TYPE_ALBUM)
albums = [response]
else:
params = {"q": query, "type": TYPE_ALBUM, "limit": limit}
response = requests.get(
f"{self.__BASE_URL}/search", params=params, headers=self.__AUTH_HEADER
).json()

albums = response.get("albums", {}).get("items", [])

if not albums:
raise NoMatchingAlbumFound

# Process each album to get details and tracklist
for album in albums:
id = album["id"]
album_details = requests.get(
f"{self.__BASE_URL}/albums/{id}", headers=self.__AUTH_HEADER
).json()

if query:
# If searching by query, fetch additional album details using the album ID
album_details = requests.get(
f"{self.__BASE_URL}/albums/{id}", headers=self.__AUTH_HEADER
).json()
else:
# If using URL, we already have full album details from the initial request
album_details = album

# Extract track names from album details
tracks = [
Expand Down Expand Up @@ -253,3 +314,36 @@ def get_album(
albumlist.append(AlbumMetadata(**metadata))

return albumlist

def get_from_url(self, url: str, type: Optional[str] = None) -> tuple[dict, str]:
"""
Fetches a track or album from a Spotify URL.

Args:
url (str): The Spotify track or album URL.
type (str, optional): The type of the URL (track or album). Defaults to None.

Returns:
tuple[dict, str]: A tuple containing:
- dict: The track or album metadata from Spotify API
- str: The type of content ('track' or 'album')

Raises:
InvalidTrackUrlError: If the URL provided is not a valid track URL.
InvalidAlbumUrlError: If the URL provided is not a valid album URL.
"""

resource_type, id = self.parse_url(url)

# Verify the URL matches the expected content type (track/album)
if type and type != resource_type:
if type == TYPE_TRACK:
raise InvalidTrackUrlError
else:
raise InvalidAlbumUrlError

response = requests.get(
f"{self.__BASE_URL}/{resource_type}s/{id}", headers=self.__AUTH_HEADER
).json()

return response, resource_type
1 change: 1 addition & 0 deletions BeatPrints/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,4 @@ def filename(song: str, artist: str) -> str:
filename = f"{safe_text}_{random_hex}.png"

return filename

37 changes: 34 additions & 3 deletions cli/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def select_album(limit: int):
qmark="💿️",
).unsafe_ask()

result = sp.get_album(query, limit, shuffle)
result = sp.get_album(query, limit=limit, shuffle=shuffle)

# Clear the screen
exutils.clear()
Expand Down Expand Up @@ -120,6 +120,28 @@ def select_album(limit: int):
return result[int(choice) - 1], index


def select_from_spotify_url():
"""
Prompt user to input track or album URL and retrieve the metadata.

Returns:
Union[TrackMetadata, AlbumMetadata]: Metadata for the track or album from Spotify URL.
"""
url = questionary.text(
"• Type the spotify track or album url:",
validate=validate.SpotifyURLValidator,
style=exutils.lavish,
qmark="🎺",
).unsafe_ask()

type, _ = sp.parse_url(url)

if type == "track":
return sp.get_track(url=url)[0]
elif type == "album":
return sp.get_album(url=url)[0]


def handle_lyrics(track: spotify.TrackMetadata):
"""
Get lyrics and let user select lines.
Expand Down Expand Up @@ -226,7 +248,7 @@ def create_poster():
"""
poster_type = questionary.select(
"• What do you want to create?",
choices=["Track Poster", "Album Poster"],
choices=["Track Poster", "Album Poster", "From Spotify URL"],
style=exutils.lavish,
qmark="🎨",
).unsafe_ask()
Expand All @@ -245,12 +267,21 @@ def create_poster():

exutils.clear()
ps.track(track, lyrics, accent, theme, image)
else:
elif poster_type == "Album Poster":
album = select_album(conf.SEARCH_LIMIT)

if album:
ps.album(*album, accent, theme, image)
else:
metadata = select_from_spotify_url()

if isinstance(metadata, spotify.TrackMetadata):
lyrics = handle_lyrics(metadata)

exutils.clear()
ps.track(metadata, lyrics, accent, theme, image)
elif isinstance(metadata, spotify.AlbumMetadata):
ps.album(metadata, False, accent, theme, image)

def main():
exutils.clear()
Expand Down
22 changes: 21 additions & 1 deletion cli/validate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import os
import os, re

from PIL import Image
from pathlib import Path
Expand Down Expand Up @@ -119,3 +119,23 @@ def validate(self, document):
message="> You can't leave the search query empty when searching.",
cursor_position=len(document.text), # Move cursor to end
)


class SpotifyURLValidator(Validator):

def validate(self, document):
url = document.text

if not url.startswith("https://open.spotify.com"):
raise ValidationError(
message="> The URL must be a Spotify URL.",
cursor_position=len(document.text),
)

pattern = r'https://open\.spotify\.com/(track|album)/[a-zA-Z0-9]{22}(?:\?.*)?$'
if not re.match(pattern, url):
raise ValidationError(
message="> Invalid Spotify URL format. Must be a track or album URL.",
cursor_position=len(document.text),
)