BeatPrints 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. beatprints-1.0.0/BeatPrints/__init__.py +4 -0
  2. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSans/NotoSans-Bold.ttf +0 -0
  3. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSans/NotoSans-Light.ttf +0 -0
  4. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSans/NotoSans-Regular.ttf +0 -0
  5. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansJP/NotoSansJP-Bold.ttf +0 -0
  6. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansJP/NotoSansJP-Light.ttf +0 -0
  7. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansJP/NotoSansJP-Regular.ttf +0 -0
  8. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansKR/NotoSansKR-Bold.ttf +0 -0
  9. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansKR/NotoSansKR-Light.ttf +0 -0
  10. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansKR/NotoSansKR-Regular.ttf +0 -0
  11. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansSC/NotoSansSC-Bold.ttf +0 -0
  12. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansSC/NotoSansSC-Light.ttf +0 -0
  13. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansSC/NotoSansSC-Regular.ttf +0 -0
  14. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansTC/NotoSansTC-Bold.ttf +0 -0
  15. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansTC/NotoSansTC-Light.ttf +0 -0
  16. beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansTC/NotoSansTC-Regular.ttf +0 -0
  17. beatprints-1.0.0/BeatPrints/assets/fonts/Oswald/Oswald-Bold.ttf +0 -0
  18. beatprints-1.0.0/BeatPrints/assets/fonts/Oswald/Oswald-Light.ttf +0 -0
  19. beatprints-1.0.0/BeatPrints/assets/fonts/Oswald/Oswald-Regular.ttf +0 -0
  20. beatprints-1.0.0/BeatPrints/assets/templates/banner_dark.png +0 -0
  21. beatprints-1.0.0/BeatPrints/assets/templates/banner_light.png +0 -0
  22. beatprints-1.0.0/BeatPrints/consts.py +51 -0
  23. beatprints-1.0.0/BeatPrints/errors.py +79 -0
  24. beatprints-1.0.0/BeatPrints/image.py +210 -0
  25. beatprints-1.0.0/BeatPrints/lyrics.py +97 -0
  26. beatprints-1.0.0/BeatPrints/poster.py +213 -0
  27. beatprints-1.0.0/BeatPrints/spotify.py +218 -0
  28. beatprints-1.0.0/BeatPrints/utils.py +96 -0
  29. beatprints-1.0.0/BeatPrints/write.py +258 -0
  30. beatprints-1.0.0/LICENSE +437 -0
  31. beatprints-1.0.0/PKG-INFO +234 -0
  32. beatprints-1.0.0/README.md +207 -0
  33. beatprints-1.0.0/pyproject.toml +32 -0
@@ -0,0 +1,4 @@
1
+ from .spotify import *
2
+ from .lyrics import *
3
+ from .poster import *
4
+ from .errors import *
@@ -0,0 +1,51 @@
1
+ """
2
+ Module: consts.py
3
+
4
+ Contains all the necessary co-ordinates that'll
5
+ be needed to place the texts/images on the poster.
6
+
7
+ Prefixes
8
+ ---------
9
+ S = Size
10
+ C = Cords
11
+ P = Path
12
+ CL = Color
13
+ """
14
+
15
+ import os
16
+
17
+ MAX_ROWS = 5
18
+ MAX_WIDTH = 1020
19
+
20
+ S_TRACKS = 35
21
+ S_SPACING = 45
22
+ S_COVER = (1020, 1020)
23
+ S_SPOTIFY_CODE = (330, 85)
24
+ S_HEADING = 80
25
+ S_ARTIST = 60
26
+ S_DURATION = 45
27
+ S_LYRICS = 42
28
+ S_LABEL = 30
29
+
30
+ C_COVER = (60, 60)
31
+ C_HEADING = (60, 1275)
32
+ C_ARTIST = (60, 1350)
33
+ C_LYRICS = (60, 1395)
34
+ C_TRACKS = (60, 1390)
35
+ C_LABEL = (1080, 1615)
36
+
37
+ C_DURATION = (1080, 1275)
38
+ C_PALETTE = (60, 1120)
39
+ C_ACCENT = (0, 1720, 1140, 1740)
40
+ C_SPOTIFY_CODE = (45, 1610)
41
+
42
+ CL_FONT_DARK_MODE = (193, 189, 178)
43
+ CL_FONT_LIGHT_MODE = (50, 47, 48)
44
+
45
+ CL_WHITE = (255, 255, 255, 255)
46
+ CL_TRANSPARENT = (0, 0, 0, 0)
47
+
48
+ P_FULLPATH = os.path.join(os.path.dirname(__file__))
49
+ P_ASSETS = os.path.join(P_FULLPATH, "assets")
50
+ P_FONTS = os.path.join(P_ASSETS, "fonts")
51
+ P_TEMPLATES = os.path.join(P_ASSETS, "templates")
@@ -0,0 +1,79 @@
1
+ """
2
+ Module: errors.py
3
+
4
+ Handles custom exceptions for error handling.
5
+ """
6
+
7
+ class NoMatchingTrackFound(Exception):
8
+ """
9
+ Exception raised when no song matching the specified query is found.
10
+ """
11
+ def __init__(self, message="No track was found matching the query."):
12
+ self.message = message
13
+ super().__init__(self.message)
14
+
15
+
16
+ class NoMatchingAlbumFound(Exception):
17
+ """
18
+ Exception raised when no album matching the specified query is found.
19
+ """
20
+ def __init__(self, message="No album was found matching the query."):
21
+ self.message = message
22
+ super().__init__(self.message)
23
+
24
+
25
+ class NoLyricsAvailable(Exception):
26
+ """
27
+ Exception raised when no lyrics are available for the specified song.
28
+ """
29
+ def __init__(self, message="No lyrics were found for the specified song"):
30
+ self.message = message
31
+ super().__init__(self.message)
32
+
33
+
34
+ class InvalidSearchLimit(Exception):
35
+ """
36
+ Exception raised when an invalid search limit is specified for tracks or albums.
37
+ """
38
+ def __init__(self, message="The search limit must be set to at least 1."):
39
+ self.message = message
40
+ super().__init__(self.message)
41
+
42
+
43
+ class InvalidSelectionError(Exception):
44
+ """
45
+ Exception raised when an invalid selection range is provided for lyrics.
46
+ """
47
+ def __init__(
48
+ self,
49
+ message="Invalid range format. Please use 'start-end', ensuring start is less than end.",
50
+ ):
51
+ self.message = message
52
+ super().__init__(self.message)
53
+
54
+
55
+ class LineLimitExceededError(Exception):
56
+ """
57
+ Exception raised when the selection in lyrics contains more or fewer than 4 lines.
58
+ """
59
+ def __init__(self, message="Exactly 4 lines must be selected, no more, no less."):
60
+ self.message = message
61
+ super().__init__(self.message)
62
+
63
+
64
+ class InvalidFormatError(Exception):
65
+ """
66
+ Exception raised when the format of the lyrics selection is invalid.
67
+ """
68
+ def __init__(self, message="Use format 'x-y' where x and y are numbers."):
69
+ self.message = message
70
+ super().__init__(self.message)
71
+
72
+
73
+ class PathNotFoundError(Exception):
74
+ """
75
+ Exception raised when the specified path for saving images cannot be found.
76
+ """
77
+ def __init__(self, message="The specified path for saving images could not be found."):
78
+ self.message = message
79
+ super().__init__(self.message)
@@ -0,0 +1,210 @@
1
+ """
2
+ Module: image.py
3
+
4
+ Provides essential image functions to generate posters.
5
+ """
6
+
7
+ import random
8
+ import requests
9
+
10
+ from io import BytesIO
11
+ from typing import List, Tuple, Optional
12
+
13
+ from Pylette import extract_colors
14
+ from PIL import Image, ImageDraw, ImageEnhance
15
+
16
+ from .consts import *
17
+ from .errors import PathNotFoundError
18
+
19
+
20
+ def get_palette(image: Image.Image) -> List[Tuple]:
21
+ """
22
+ Extracts a color palette from an image.
23
+
24
+ Args:
25
+ image (Image.Image): The image from which the color palette will be extracted.
26
+
27
+ Returns:
28
+ List[Tuple]: A list of RGB tuples representing the dominant colors in the image.
29
+ """
30
+
31
+ # Save the image to an in-memory byte stream in PNG format
32
+ with BytesIO() as byte_stream:
33
+ image.save(byte_stream, format="PNG")
34
+
35
+ # Get the byte data
36
+ img_bytes = byte_stream.getvalue()
37
+
38
+ # Extract the color palette
39
+ colors = extract_colors(image=img_bytes, palette_size=6, sort_mode="luminance")
40
+
41
+ # Return the color palette
42
+ return [tuple(color.rgb) for color in colors]
43
+
44
+
45
+ def draw_palette(
46
+ draw: ImageDraw.ImageDraw, image: Image.Image, accent: bool = False
47
+ ) -> None:
48
+ """
49
+ Draws a color palette on the given image using Pillow.
50
+
51
+ Args:
52
+ draw (ImageDraw.ImageDraw): The drawing context for the image.
53
+ image (Image.Image): The image on which to draw the palette.
54
+ accent (bool): If True, highlights an accent color in the palette.
55
+ """
56
+
57
+ palette = get_palette(image)
58
+
59
+ # Draw the palette as rectangles on the image
60
+ for i in range(6):
61
+
62
+ # Calculate the position and size for each color rectangle
63
+ x, y = C_PALETTE
64
+ start, end = 170 * i, 170 * (i + 1)
65
+
66
+ # Draw the color rectangle
67
+ draw.rectangle(((x + start, y), (x + end, 1160)), fill=palette[i])
68
+
69
+ # Optionally draw the accent color at the bottom
70
+ if accent:
71
+ draw.rectangle(C_ACCENT, fill=palette[random.randint(0, 2)])
72
+
73
+
74
+ def crop(path: str) -> Image.Image:
75
+ """
76
+ Crops an image to a square (1:1) aspect ratio.
77
+
78
+ Args:
79
+ path (str): The path to the image file.
80
+
81
+ Returns:
82
+ Image.Image: The cropped image with a 1:1 aspect ratio.
83
+
84
+ Raises:
85
+ PathNotFoundError: If the path to the image file doesn't exists
86
+ """
87
+
88
+ def chop(image: Image.Image) -> Image.Image:
89
+ # Get the dimensions of the image
90
+ width, height = image.size
91
+ min_dim = min(width, height)
92
+
93
+ # Calculate the cropping box to make the image square
94
+ left = (width - min_dim) / 2
95
+ top = (height - min_dim) / 2
96
+ right = (width + min_dim) / 2
97
+ bottom = (height + min_dim) / 2
98
+
99
+ # Crop and return the image
100
+ return image.crop((left, top, right, bottom))
101
+
102
+ if os.path.exists(path):
103
+ with Image.open(path) as img:
104
+ return chop(img)
105
+ else:
106
+ raise PathNotFoundError
107
+
108
+
109
+ def magicify(image: Image.Image) -> Image.Image:
110
+ """
111
+ Adjusts the brightness and contrast of the image.
112
+
113
+ Args:
114
+ image (Image.Image): The image to be adjusted.
115
+
116
+ Returns:
117
+ Image.Image: The adjusted image with modified brightness and contrast.
118
+ """
119
+
120
+ # Reduce brightness by 10%
121
+ brightness = ImageEnhance.Brightness(image)
122
+ image_brightness = brightness.enhance(0.9)
123
+
124
+ # Reduce contrast by 20%
125
+ contrast = ImageEnhance.Contrast(image_brightness)
126
+ image_contrast = contrast.enhance(0.8)
127
+
128
+ return image_contrast
129
+
130
+
131
+ def scannable(id: str, darktheme: bool = False, is_album: bool = False) -> Image.Image:
132
+ """
133
+ Generates a Spotify scannable code (QR code) for a track or album.
134
+
135
+ Args:
136
+ id (str): The ID of the track or album on Spotify.
137
+ darktheme (bool): Flag to indicate whether to use a dark theme (default is False).
138
+ is_album (bool): Flag to indicate if the ID is for an album (default is False).
139
+
140
+ Returns:
141
+ Image.Image: A resized and transparent Spotify scannable code image.
142
+ """
143
+
144
+ # Set the color based on the theme
145
+ color = CL_FONT_DARK_MODE if darktheme else CL_FONT_LIGHT_MODE
146
+
147
+ # Build the URL for the Spotify scannable code
148
+ item_type = "album" if is_album else "track"
149
+ scan_url = f"https://scannables.scdn.co/uri/plain/png/101010/white/1280/spotify:{item_type}:{id}"
150
+
151
+ # Download the scannable image data
152
+ data = requests.get(scan_url).content
153
+ img_bytes = BytesIO(data)
154
+
155
+ # Process the image to make white pixels transparent
156
+ with Image.open(img_bytes) as scan_code:
157
+
158
+ # Convert the image to RGBA mode to support transparency
159
+ scan_code = scan_code.convert("RGBA")
160
+ width, height = scan_code.size
161
+ pixels = scan_code.load()
162
+
163
+ # Replace white pixels with transparency, others with the selected color
164
+ for x in range(width):
165
+ for y in range(height):
166
+ if pixels is not None:
167
+ pixels[x, y] = CL_TRANSPARENT if pixels[x, y] != CL_WHITE else color
168
+
169
+ # Resize the image to specific size
170
+ resized = scan_code.resize(S_SPOTIFY_CODE, Image.Resampling.BICUBIC)
171
+
172
+ return resized
173
+
174
+
175
+ def cover(image_url: str, path: Optional[str]) -> Image.Image:
176
+ """
177
+ Fetches and processes an image from a URL or a local path.
178
+
179
+ Args:
180
+ image_url (str): The URL to fetch the image from.
181
+ path (Optional[str]): Local path to the custom image.
182
+
183
+ Returns:
184
+ Image.Image: The processed image.
185
+ """
186
+
187
+ # Load image from a path or fetch from URL
188
+ img = crop(path) if path else Image.open(BytesIO(requests.get(image_url).content))
189
+
190
+ # Apply magic filter and resize
191
+ return magicify(img.resize(S_COVER))
192
+
193
+
194
+ def get_theme(dark_theme: bool):
195
+ """
196
+ Determines theme-related properties.
197
+
198
+ Args:
199
+ dark_theme (bool): Whether to use a dark theme.
200
+
201
+ Returns:
202
+ Tuple containing theme color, template path
203
+ """
204
+ color, template = (
205
+ (CL_FONT_DARK_MODE, "banner_dark.png")
206
+ if dark_theme
207
+ else (CL_FONT_LIGHT_MODE, "banner_light.png")
208
+ )
209
+ template_path = os.path.join(P_TEMPLATES, template)
210
+ return color, template_path
@@ -0,0 +1,97 @@
1
+ """
2
+ Module: lyrics.py
3
+
4
+ Provide lyrics from LRClib API.
5
+ """
6
+
7
+ import re
8
+ from lrclib import LrcLibAPI
9
+
10
+ from .spotify import TrackMetadata
11
+ from .errors import (
12
+ NoLyricsAvailable,
13
+ InvalidFormatError,
14
+ InvalidSelectionError,
15
+ LineLimitExceededError,
16
+ )
17
+
18
+
19
+ class Lyrics:
20
+ """
21
+ This class helps to retrieve lyrics through LRClib API.
22
+ """
23
+
24
+ def get_lyrics(self, metadata: TrackMetadata) -> str:
25
+ """
26
+ Retrieve lyrics from LRClib.net for a given track and artist.
27
+
28
+ Args:
29
+ metadata (TrackMetadata): Instance that holds the metadata about a track.
30
+
31
+ Returns:
32
+ str: The lyrics in plain text if found.
33
+
34
+ Raises:
35
+ NoLyricsAvailable: If no lyrics are found for the given track and artist.
36
+ """
37
+ user_agent = (
38
+ "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"
39
+ )
40
+
41
+ # Prepare the API
42
+ api = LrcLibAPI(user_agent=user_agent)
43
+ id = api.search_lyrics(track_name=metadata.name, artist_name=metadata.artist)
44
+
45
+ # Check if lyrics are available
46
+ if len(id) != 0:
47
+ lyrics = api.get_lyrics_by_id(id[0].id)
48
+ return str(lyrics.plain_lyrics)
49
+ else:
50
+ raise NoLyricsAvailable
51
+
52
+ def select_lines(self, lyrics: str, selection: str) -> str:
53
+ """
54
+ Selects a range of lines from the song lyrics.
55
+
56
+ Args:
57
+ lyrics (str): The lyrics of the song.
58
+ selection (str): Line range to extract in "start-end" format.
59
+
60
+ Returns:
61
+ str: The selected lines.
62
+
63
+ Raises:
64
+ InvalidFormatError: If the selection format is invalid.
65
+ InvalidSelectionError: If the selection range is invalid.
66
+ LineLimitExceededError: If the selected range doesn't contain exactly 4 lines.
67
+ """
68
+ lines = [line for line in lyrics.split("\n")]
69
+ line_count = len(lines)
70
+
71
+ try:
72
+ pattern = r"^\d+-\d+$"
73
+
74
+ if not re.match(pattern, selection):
75
+ raise InvalidFormatError
76
+
77
+ selected = [int(num) for num in selection.split("-")]
78
+
79
+ if (
80
+ len(selected) != 2
81
+ or selected[0] >= selected[1]
82
+ or selected[0] <= 0
83
+ or selected[1] > line_count
84
+ ):
85
+ raise InvalidSelectionError
86
+
87
+ extracted = lines[selected[0] - 1 : selected[1]]
88
+ selected_lines = [line for line in extracted if line != ""]
89
+
90
+ if len(selected_lines) != 4:
91
+ raise LineLimitExceededError
92
+
93
+ quatrain = "\n".join(selected_lines).strip()
94
+ return quatrain
95
+
96
+ except Exception as e:
97
+ raise e