syncify-py 1.0.0__py3-none-any.whl

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.
syncify/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Syncify - Spotify track and playlist metadata library."""
2
+
3
+ from syncify.spotify.Spotify_playlist_info import PlaylistDetails, get_playlist
4
+ from syncify.spotify.Spotify_track_info import TrackDetails, get_track
5
+
6
+ __all__ = ["get_track", "get_playlist", "TrackDetails", "PlaylistDetails"]
syncify/__main__.py ADDED
@@ -0,0 +1,79 @@
1
+ """CLI entry point for python -m syncify."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from syncify.spotify.Spotify_playlist_info import PlaylistDetails, get_playlist
7
+ from syncify.spotify.Spotify_track_info import TrackDetails, get_track
8
+ from syncify.spotify.utils import get_link_type
9
+
10
+
11
+ def _print_track(details: TrackDetails) -> None:
12
+ print("Track:")
13
+ print(f" URL : {details.spotify_url or '(empty)'}")
14
+ print(f" ID : {details.track_id or '(empty)'}")
15
+ print(f" Title : {details.track_title or '(empty)'}")
16
+ print(f" Artist : {details.artist_title or '(empty)'}")
17
+ print(f" Image : {details.track_image_url or '(empty)'}")
18
+
19
+
20
+ def _print_playlist(details: PlaylistDetails) -> None:
21
+ print("Playlist:")
22
+ print(f" URL : {details.playlist_url or '(empty)'}")
23
+ print(f" ID : {details.playlist_id or '(empty)'}")
24
+ print(f" Title : {details.title or '(empty)'}")
25
+ print(f" Tracks : {len(details.track_urls)}")
26
+ print(f" Image : {details.playlist_image_url or '(empty)'}")
27
+ for i, url in enumerate(details.track_urls, 1):
28
+ print(f" {i:>3}. {url}")
29
+
30
+
31
+ def _run(urls: list[str]) -> int:
32
+ for i, url in enumerate(urls, 1):
33
+ link_type = get_link_type(url)
34
+ print("=" * 60)
35
+ print(f"[{i}] {url}")
36
+ print(f"Type: {link_type or 'Invalid'}")
37
+
38
+ if link_type == "Track":
39
+ try:
40
+ _print_track(get_track(url))
41
+ except Exception as e:
42
+ print(f"Error: {e}")
43
+ return 1
44
+ elif link_type == "Playlist":
45
+ try:
46
+ _print_playlist(get_playlist(url))
47
+ except Exception as e:
48
+ print(f"Error: {e}")
49
+ return 1
50
+ else:
51
+ print("Invalid Spotify URL. Use track or playlist links.")
52
+ return 1
53
+ return 0
54
+
55
+
56
+ def main() -> int:
57
+ parser = argparse.ArgumentParser(description="Fetch Spotify track or playlist details.")
58
+ group = parser.add_mutually_exclusive_group()
59
+ group.add_argument("--track", metavar="URL", help="Fetch track details")
60
+ group.add_argument("--playlist", metavar="URL", help="Fetch playlist details")
61
+ parser.add_argument("urls", nargs="*", metavar="URL", help="Spotify URLs (auto-detect)")
62
+ args = parser.parse_args()
63
+
64
+ if args.track:
65
+ urls = [args.track]
66
+ elif args.playlist:
67
+ urls = [args.playlist]
68
+ elif args.urls:
69
+ urls = args.urls
70
+ else:
71
+ print("Usage: python -m syncify <url> [url ...]")
72
+ print(" python -m syncify --track <url>")
73
+ print(" python -m syncify --playlist <url>")
74
+ return 1
75
+ return _run(urls)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -0,0 +1,299 @@
1
+ """
2
+ spotify_playlist_info.py
3
+ ------------------------
4
+ Standalone Python library to fetch Spotify playlist/track details.
5
+
6
+ Dependencies:
7
+ pip install selenium beautifulsoup4 requests webdriver-manager
8
+
9
+ Usage:
10
+ from spotify_playlist_info import SpotifyPlaylistInfo
11
+
12
+ info = SpotifyPlaylistInfo()
13
+ details = info.get_playlist_details("https://open.spotify.com/playlist/...")
14
+ print(details.title)
15
+ print(details.track_urls)
16
+ """
17
+
18
+ import re
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from typing import List, Optional
22
+
23
+ import requests
24
+ from bs4 import BeautifulSoup
25
+ from selenium import webdriver
26
+ from selenium.webdriver.chrome.options import Options
27
+ from selenium.webdriver.common.by import By
28
+ from selenium.webdriver.support import expected_conditions as EC
29
+ from selenium.webdriver.support.ui import WebDriverWait
30
+ from selenium.common.exceptions import WebDriverException
31
+ from selenium.webdriver.chrome.service import Service
32
+ from webdriver_manager.chrome import ChromeDriverManager
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Regex constants (from shared utils)
37
+ # ---------------------------------------------------------------------------
38
+ from syncify.spotify.utils import (
39
+ get_link_type,
40
+ is_valid_link,
41
+ PLAYLIST_REGEX,
42
+ TRACK_REGEX,
43
+ canonicalize_spotify_url,
44
+ )
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Data class
49
+ # ---------------------------------------------------------------------------
50
+ @dataclass
51
+ class PlaylistDetails:
52
+ """Holds the result of a playlist scrape."""
53
+ # Use empty defaults so missing data is represented by empty fields.
54
+ playlist_url: str = ""
55
+ playlist_id: str = ""
56
+ title: str = ""
57
+ playlist_image_url: str = ""
58
+ track_urls: List[str] = field(default_factory=list)
59
+
60
+ def __repr__(self) -> str:
61
+ return f"PlaylistDetails(title={self.title!r}, tracks={len(self.track_urls)})"
62
+
63
+
64
+ def get_song_name_from_url(url: str) -> str:
65
+ """
66
+ Fetch a Spotify track page and extract the song title from the
67
+ og:title meta tag.
68
+
69
+ Args:
70
+ url: A Spotify track URL.
71
+
72
+ Returns:
73
+ The song title string, or 'Song title not found.' on failure.
74
+ """
75
+ headers = {
76
+ "User-Agent": (
77
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
78
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
79
+ "Chrome/114.0.0.0 Safari/537.36"
80
+ )
81
+ }
82
+ resp = requests.get(url, headers=headers, timeout=15)
83
+ resp.raise_for_status()
84
+ soup = BeautifulSoup(resp.text, "html.parser")
85
+ tag = soup.find("meta", property="og:title")
86
+ if tag and tag.get("content"):
87
+ return tag["content"]
88
+ return "Song title not found."
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Public API
93
+ # ---------------------------------------------------------------------------
94
+ def get_playlist(url: str) -> PlaylistDetails:
95
+ """
96
+ Fetch playlist details from a Spotify playlist URL.
97
+
98
+ Args:
99
+ url: Full Spotify playlist URL (e.g. https://open.spotify.com/playlist/...).
100
+
101
+ Returns:
102
+ PlaylistDetails with title, track_urls, playlist_image_url.
103
+ """
104
+ return SpotifyPlaylistInfo().get_playlist(url)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Main class
109
+ # ---------------------------------------------------------------------------
110
+ class SpotifyPlaylistInfo:
111
+ """
112
+ Scrapes a Spotify playlist page using Selenium (headless Chrome) and
113
+ returns all track URLs along with the playlist title.
114
+ Uses webdriver-manager to manage ChromeDriver automatically.
115
+
116
+ Args:
117
+ page_load_timeout: Seconds to wait for the page to load (default 30).
118
+ scroll_pause: Seconds to pause between scroll steps (default 2).
119
+ initial_wait: Seconds to wait after first page load (default 10).
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ page_load_timeout: int = 30,
125
+ scroll_pause: float = 2.0,
126
+ initial_wait: float = 10.0,
127
+ ) -> None:
128
+ self.page_load_timeout = page_load_timeout
129
+ self.scroll_pause = scroll_pause
130
+ self.initial_wait = initial_wait
131
+ self.playlist_title: str = ""
132
+
133
+ # ------------------------------------------------------------------
134
+ # Public API
135
+ # ------------------------------------------------------------------
136
+ def get_playlist(self, url: str) -> PlaylistDetails:
137
+ """
138
+ Scrape all track URLs and the title from a Spotify playlist page.
139
+
140
+ Args:
141
+ url: Full Spotify playlist URL.
142
+
143
+ Returns:
144
+ A :class:`PlaylistDetails` instance.
145
+
146
+ Raises:
147
+ ValueError: If *url* is not a Spotify playlist link.
148
+ Exception: On any Selenium / network error.
149
+ """
150
+ if get_link_type(url) != "Playlist":
151
+ raise ValueError(f"{url!r} is not a Spotify playlist link.")
152
+
153
+ # Initialise details with URL and (if possible) playlist ID extracted from it.
154
+ details = PlaylistDetails(playlist_url=url)
155
+ canonical = canonicalize_spotify_url(url)
156
+ match = re.match(PLAYLIST_REGEX, canonical)
157
+ if match:
158
+ details.playlist_id = match.group(1)
159
+
160
+ driver = self._build_driver()
161
+ links_found: List[str] = []
162
+
163
+ try:
164
+ driver.set_page_load_timeout(self.page_load_timeout)
165
+ driver.get(url)
166
+ time.sleep(self.initial_wait)
167
+
168
+ # ---- grab playlist title ----
169
+ title_el = driver.find_element(
170
+ By.CSS_SELECTOR, 'span[data-testid="entityTitle"]'
171
+ )
172
+ self.playlist_title = title_el.text
173
+
174
+ # ---- grab playlist image URL (mosaic cover) ----
175
+ # The main playlist image is rendered inside a container with
176
+ # data-testid="playlist-image", which holds an <img> whose src is
177
+ # the mosaic URL like the one you provided.
178
+ playlist_image_url = ""
179
+ try:
180
+ image_el = driver.find_element(
181
+ By.CSS_SELECTOR, 'div[data-testid="playlist-image"] img'
182
+ )
183
+ playlist_image_url = image_el.get_attribute("src") or ""
184
+ except Exception:
185
+ # If we fail to locate the image, keep the field empty.
186
+ playlist_image_url = ""
187
+
188
+ # ---- scroll and collect track links, accounting for virtualized rows ----
189
+ # The Spotify UI virtualizes the tracklist, so the number of DOM rows can
190
+ # stay roughly constant while the actual songs change as you scroll.
191
+ # To handle this, we:
192
+ # 1) repeatedly scroll to the last visible row
193
+ # 2) on each iteration, collect any new track URLs we see
194
+ # 3) stop when we stop discovering new links for several iterations
195
+ stable_loops = 0
196
+ max_stable_loops = 5
197
+ max_scrolls = 200
198
+ prev_count = 0
199
+
200
+ for _ in range(max_scrolls):
201
+ # collect links currently in the DOM, but only from the main
202
+ # playlist tracklist (exclude the separate "Recommended" list).
203
+ rows = driver.find_elements(
204
+ By.CSS_SELECTOR,
205
+ 'div[data-testid="playlist-tracklist"] div[data-testid="tracklist-row"]',
206
+ )
207
+ for row in rows:
208
+ try:
209
+ anchor = row.find_element(
210
+ By.CSS_SELECTOR, 'a[data-testid="internal-track-link"]'
211
+ )
212
+ href = anchor.get_attribute("href") or ""
213
+ if is_valid_link(href) and href not in links_found:
214
+ links_found.append(href)
215
+ except Exception:
216
+ # Some rows may be ads or separators – skip them silently.
217
+ pass
218
+
219
+ current_count = len(links_found)
220
+ if current_count == prev_count:
221
+ stable_loops += 1
222
+ if stable_loops >= max_stable_loops:
223
+ break
224
+ else:
225
+ stable_loops = 0
226
+ prev_count = current_count
227
+
228
+ # scroll to the last visible row in the main playlist tracklist
229
+ # to trigger loading more items (and not the recommended rows)
230
+ driver.execute_script(
231
+ """
232
+ const rows = document.querySelectorAll(
233
+ "div[data-testid='playlist-tracklist'] div[data-testid='tracklist-row']"
234
+ );
235
+ if (rows.length) {
236
+ rows[rows.length - 1].scrollIntoView({ behavior: 'smooth', block: 'end' });
237
+ }
238
+ """
239
+ )
240
+ time.sleep(self.scroll_pause)
241
+
242
+ finally:
243
+ driver.quit()
244
+
245
+ details.title = self.playlist_title
246
+ details.playlist_image_url = playlist_image_url
247
+ details.track_urls = links_found
248
+
249
+ return details
250
+
251
+ # ------------------------------------------------------------------
252
+ # Private helpers
253
+ # ------------------------------------------------------------------
254
+ def _build_driver(self) -> webdriver.Chrome:
255
+ """Construct and return a headless Chrome WebDriver.
256
+
257
+ We rely on Selenium Manager to locate a matching ChromeDriver
258
+ for the locally installed Chrome (no webdriver-manager needed).
259
+ """
260
+ chrome_options = Options()
261
+ chrome_options.add_argument("--headless")
262
+ chrome_options.add_argument("--disable-gpu")
263
+ chrome_options.add_argument("--no-sandbox")
264
+ chrome_options.add_argument("--incognito")
265
+ chrome_options.add_argument(
266
+ "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
267
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
268
+ "Chrome/114.0.0.0 Safari/537.36"
269
+ )
270
+ chrome_options.add_argument("--remote-allow-origins=*")
271
+ chrome_options.add_argument("--disable-blink-features=AutomationControlled")
272
+
273
+ # Prefer Selenium Manager (Selenium 4+) but fallback to webdriver-manager
274
+ # for environments where Selenium Manager isn't available / can't resolve.
275
+ try:
276
+ return webdriver.Chrome(options=chrome_options)
277
+ except WebDriverException:
278
+ service = Service(ChromeDriverManager().install())
279
+ return webdriver.Chrome(service=service, options=chrome_options)
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Quick CLI test
284
+ # ---------------------------------------------------------------------------
285
+ if __name__ == "__main__":
286
+ import sys
287
+
288
+ if len(sys.argv) < 2:
289
+ print("Usage: python spotify_playlist_info.py <spotify_playlist_url>")
290
+ sys.exit(1)
291
+
292
+ playlist_url = sys.argv[1]
293
+ scraper = SpotifyPlaylistInfo()
294
+ result = scraper.get_playlist(playlist_url)
295
+
296
+ print(f"Playlist : {result.title}")
297
+ print(f"Tracks : {len(result.track_urls)}")
298
+ for i, t in enumerate(result.track_urls, 1):
299
+ print(f" {i:>3}. {t}")
@@ -0,0 +1,177 @@
1
+ """
2
+ Spotify_track_info.py
3
+ ---------------------
4
+ Lightweight helpers for working with Spotify track / playlist URLs and for
5
+ extracting metadata for a single Spotify track.
6
+
7
+ This module is **Spotify‑only**: it does not download audio or touch yt-dlp.
8
+ Downloading and further processing are handled in a separate `youtube` module.
9
+ """
10
+
11
+ import logging
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ from selenium import webdriver
17
+ from selenium.common.exceptions import WebDriverException
18
+ from selenium.webdriver.common.by import By
19
+ from selenium.webdriver.chrome.options import Options
20
+ from selenium.webdriver.chrome.service import Service
21
+ from selenium.webdriver.support.ui import WebDriverWait
22
+ from selenium.webdriver.support import expected_conditions as EC
23
+ from webdriver_manager.chrome import ChromeDriverManager
24
+
25
+ from syncify.spotify.utils import (
26
+ PLAYLIST_REGEX,
27
+ TRACK_REGEX,
28
+ canonicalize_spotify_url,
29
+ )
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Logging
34
+ # ---------------------------------------------------------------------------
35
+ # Use a module-specific logger and keep external driver logs quiet.
36
+ logging.basicConfig(
37
+ level=logging.INFO,
38
+ format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
39
+ )
40
+ LOG = logging.getLogger("SpotifyTrackInfo")
41
+ logging.getLogger("WDM").setLevel(logging.WARNING)
42
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Regex helpers (ported 1-to-1 from Java)
47
+ # ---------------------------------------------------------------------------
48
+ YOUTUBE_URL_REGEX = (
49
+ r"^(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/watch\?v=|youtu\.be/)"
50
+ r"([a-zA-Z0-9_-]{11})"
51
+ )
52
+ YOUTUBE_URL_PATTERN = re.compile(YOUTUBE_URL_REGEX)
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Data model for track details
56
+ # ---------------------------------------------------------------------------
57
+ @dataclass
58
+ class TrackDetails:
59
+ """
60
+ Holds all gathered information for a single Spotify track.
61
+
62
+ All string fields default to the empty string so that missing data
63
+ is represented as empty fields instead of None.
64
+ """
65
+
66
+ spotify_url: str = ""
67
+ track_id: str = ""
68
+ track_title: str = ""
69
+ artist_title: str = ""
70
+ track_image_url: str = ""
71
+
72
+
73
+ def is_spotify_link(url: str) -> bool:
74
+ """Return True if *url* is a Spotify track or playlist URL."""
75
+ canonical = canonicalize_spotify_url(url)
76
+ return bool(re.match(TRACK_REGEX, canonical) or re.match(PLAYLIST_REGEX, canonical))
77
+
78
+
79
+ def extract_youtube_video_id(url: str) -> Optional[str]:
80
+ """Return the 11‑char YouTube video ID from a full URL, or None."""
81
+ match = YOUTUBE_URL_PATTERN.match(url)
82
+ return match.group(1) if match else None
83
+
84
+
85
+ def is_valid_youtube_url(url: str) -> bool:
86
+ """Quick validation that a URL looks like a YouTube watch / short link."""
87
+ return extract_youtube_video_id(url) is not None
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Selenium-based scraping
92
+ # ---------------------------------------------------------------------------
93
+ def _build_chrome_driver() -> webdriver.Chrome:
94
+ """
95
+ Create a headless Chrome WebDriver.
96
+
97
+ We rely on Selenium Manager to locate a matching ChromeDriver for
98
+ the locally installed Chrome (no webdriver-manager needed).
99
+ """
100
+ options = Options()
101
+ options.add_argument("--headless")
102
+ options.add_argument("--disable-gpu")
103
+ options.add_argument("--no-sandbox")
104
+ options.add_argument("--incognito")
105
+ options.add_argument(
106
+ "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
107
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
108
+ )
109
+ options.add_argument("--remote-allow-origins=*")
110
+ options.add_argument("--disable-dev-shm-usage")
111
+
112
+ # Prefer Selenium Manager (Selenium 4+) but fallback to webdriver-manager
113
+ # for environments where Selenium Manager isn't available / can't resolve.
114
+ try:
115
+ return webdriver.Chrome(options=options)
116
+ except WebDriverException:
117
+ service = Service(ChromeDriverManager().install())
118
+ return webdriver.Chrome(service=service, options=options)
119
+
120
+
121
+ def get_track(url: str) -> TrackDetails:
122
+ """
123
+ Fetch track metadata from a Spotify track URL.
124
+
125
+ Args:
126
+ url: Full Spotify track URL (e.g. https://open.spotify.com/track/...).
127
+
128
+ Returns:
129
+ TrackDetails with track_id, track_title, artist_title, track_image_url.
130
+ """
131
+ driver = _build_chrome_driver()
132
+ details = TrackDetails(spotify_url=url)
133
+
134
+ # Try to extract the track ID directly from the URL using TRACK_REGEX.
135
+ canonical = canonicalize_spotify_url(url)
136
+ match = re.match(TRACK_REGEX, canonical)
137
+ if match:
138
+ details.track_id = match.group(1)
139
+
140
+ try:
141
+ wait = WebDriverWait(driver, 30)
142
+
143
+ # ── Step 1: Spotify page ──────────────────────────────────────────
144
+ driver.get(url)
145
+ track_name_el = wait.until(
146
+ EC.presence_of_element_located(
147
+ (By.CSS_SELECTOR, 'span[data-testid="entityTitle"]')
148
+ )
149
+ )
150
+ track_artist_el = driver.find_element(
151
+ By.CSS_SELECTOR, 'a[data-testid="creator-link"]'
152
+ )
153
+ # Try to grab the main track artwork image from the header section.
154
+ # Avoid relying on the dynamic class name (e.g. "fNnrSm2k2IonbI9c");
155
+ # instead, use the stable "contentSpacing" class and Spotify image URL.
156
+ try:
157
+ track_img_el = driver.find_element(
158
+ By.CSS_SELECTOR,
159
+ "div.contentSpacing img[loading='lazy'][src^='https://i.scdn.co/image/']",
160
+ )
161
+ details.track_image_url = track_img_el.get_attribute("src") or ""
162
+ except Exception:
163
+ # Image URL is optional; keep it empty if not found.
164
+ details.track_image_url = ""
165
+
166
+ details.track_title = track_name_el.text.strip()
167
+ details.artist_title = track_artist_el.text.strip()
168
+ LOG.debug(
169
+ "Spotify track: '%s' by '%s'", details.track_title, details.artist_title
170
+ )
171
+
172
+ except Exception as exc:
173
+ LOG.debug("Error in get_track: %s", exc)
174
+ finally:
175
+ driver.quit()
176
+
177
+ return details
@@ -0,0 +1 @@
1
+ # Syncify Spotify package
@@ -0,0 +1,36 @@
1
+ """Shared Spotify URL helpers."""
2
+
3
+ import re
4
+ from urllib.parse import urlparse
5
+ from typing import Optional
6
+
7
+ _ID = r"([a-zA-Z0-9]+)"
8
+ TRACK_REGEX = rf"^https?://open\.spotify\.com/track/{_ID}/?$"
9
+ PLAYLIST_REGEX = rf"^https?://open\.spotify\.com/playlist/{_ID}/?$"
10
+
11
+
12
+ def canonicalize_spotify_url(url: str) -> str:
13
+ """
14
+ Strip query/fragment and normalize for matching.
15
+
16
+ Spotify links often include tracking params like '?si=...'.
17
+ """
18
+ parsed = urlparse(url.strip())
19
+ if not parsed.scheme or not parsed.netloc:
20
+ return url.strip()
21
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
22
+
23
+
24
+ def get_link_type(url: str) -> Optional[str]:
25
+ """Return 'Track', 'Playlist', or None for invalid URLs."""
26
+ canonical = canonicalize_spotify_url(url)
27
+ if re.match(TRACK_REGEX, canonical):
28
+ return "Track"
29
+ if re.match(PLAYLIST_REGEX, canonical):
30
+ return "Playlist"
31
+ return None
32
+
33
+
34
+ def is_valid_link(url: str) -> bool:
35
+ """Return True if url is a valid Spotify track or playlist URL."""
36
+ return get_link_type(url) is not None
@@ -0,0 +1,262 @@
1
+ Metadata-Version: 2.4
2
+ Name: syncify-py
3
+ Version: 1.0.0
4
+ Summary: Spotify track and playlist metadata library
5
+ Author: adelelawady
6
+ License: MIT
7
+ Keywords: spotify,playlist,track,metadata
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.7.0
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests>=2.28.0
19
+ Requires-Dist: beautifulsoup4>=4.12.0
20
+ Requires-Dist: selenium>=4.15.0
21
+ Requires-Dist: webdriver-manager>=4.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: mutagen>=1.47.0; extra == "dev"
24
+ Dynamic: requires-python
25
+
26
+ <!--
27
+ NOTE:
28
+ - This README assumes the GitHub repo will be published as: adelelawady/Syncify
29
+ - Update REPO_NAME below if you rename the repository.
30
+ -->
31
+
32
+ <div align="center">
33
+
34
+ ![GitHub stars](https://img.shields.io/github/stars/adelelawady/Syncify?style=for-the-badge)
35
+ ![GitHub forks](https://img.shields.io/github/forks/adelelawady/Syncify?style=for-the-badge)
36
+ ![License](https://img.shields.io/github/license/adelelawady/Syncify?style=for-the-badge)
37
+ ![Repo size](https://img.shields.io/github/repo-size/adelelawady/Syncify?style=for-the-badge)
38
+ ![Last commit](https://img.shields.io/github/last-commit/adelelawady/Syncify?style=for-the-badge)
39
+ ![Issues](https://img.shields.io/github/issues/adelelawady/Syncify?style=for-the-badge)
40
+ ![Top language](https://img.shields.io/github/languages/top/adelelawady/Syncify?style=for-the-badge)
41
+ ![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB?style=for-the-badge&logo=python&logoColor=white)
42
+ ![Selenium](https://img.shields.io/badge/Selenium-43B02A?style=for-the-badge&logo=selenium&logoColor=white)
43
+
44
+ </div>
45
+
46
+ # πŸš€ Syncify
47
+
48
+ **Syncify** is a Python **library + CLI** that fetches **Spotify track and playlist metadata** directly from `open.spotify.com` pages β€” great for quick metadata lookups, playlist introspection, and tooling where you don’t want to wire up OAuth.
49
+
50
+ > **Heads up**: Syncify scrapes Spotify’s web UI (via Selenium). Selectors can break if Spotify updates their site.
51
+
52
+ ## ✨ Features
53
+
54
+ - **Track metadata**: title, artist, cover image URL, and track ID from a track URL
55
+ - **Playlist metadata**: playlist title, cover image URL, playlist ID, and **all track URLs**
56
+ - **CLI-first**: run `syncify <url>` or `python -m syncify ...`
57
+ - **Auto-detect URLs**: mix track + playlist URLs in one command
58
+ - **No OAuth setup**: does **not** require Spotify API keys/tokens
59
+
60
+ ## 🧠 How It Works
61
+
62
+ - **Input**: Spotify track/playlist URLs (`https://open.spotify.com/track/...`, `https://open.spotify.com/playlist/...`)
63
+ - **URL detection**: a lightweight regex-based detector determines whether each URL is a Track or Playlist
64
+ - **Extraction**:
65
+ - **Tracks**: Selenium loads the page and extracts title/artist/image from page elements
66
+ - **Playlists**: Selenium loads the page, scrolls through the track list, and collects every track link
67
+ - **Output**:
68
+ - **Library**: returns dataclasses (`TrackDetails`, `PlaylistDetails`)
69
+ - **CLI**: prints a readable summary plus playlist track URLs
70
+
71
+ ## πŸ›  Tech Stack
72
+
73
+ - **Language**: Python
74
+ - **Automation/scraping**: Selenium (headless Chrome)
75
+ - **Driver management**: `webdriver-manager` (fallback if Selenium driver resolution fails)
76
+ - **HTML parsing (small helper)**: BeautifulSoup4
77
+ - **HTTP**: `requests`
78
+
79
+ ## πŸ“¦ Installation
80
+
81
+ ### Prerequisites
82
+
83
+ - **Python**: 3.9+ recommended (packaging allows older, but tested targets are 3.9–3.12)
84
+ - **Google Chrome** installed (used by Selenium)
85
+
86
+ ### Install from GitHub (recommended)
87
+
88
+ ```bash
89
+ pip install "git+https://github.com/adelelawady/Syncify.git"
90
+ ```
91
+
92
+ ### Install locally (for development)
93
+
94
+ ```bash
95
+ git clone https://github.com/adelelawady/Syncify.git
96
+ cd Syncify
97
+ pip install -e ".[dev]"
98
+ ```
99
+
100
+ ### Install from source tree (non-editable)
101
+
102
+ ```bash
103
+ pip install .
104
+ ```
105
+
106
+ ## βš™οΈ Configuration
107
+
108
+ Syncify has **no required environment variables**.
109
+
110
+ ### Runtime requirements
111
+
112
+ - **Chrome available on PATH / installed normally**
113
+ - **Chromedriver** is handled automatically via Selenium’s driver resolution, with a fallback to `webdriver-manager`.
114
+
115
+ ### Troubleshooting
116
+
117
+ - If Selenium can’t start Chrome:
118
+ - Ensure Chrome is installed and up to date.
119
+ - Try upgrading Selenium and webdriver-manager:
120
+
121
+ ```bash
122
+ pip install -U selenium webdriver-manager
123
+ ```
124
+
125
+ - If playlist results are incomplete:
126
+ - Spotify’s UI loads tracks lazily; the scraper scrolls, but very large playlists may take longer.
127
+
128
+ ## πŸš€ Usage
129
+
130
+ ## **As a library**
131
+
132
+ ```python
133
+ from syncify import get_track, get_playlist
134
+
135
+ track = get_track("https://open.spotify.com/track/5nJ4Zzqc2UjwSaIcv7bGjx")
136
+ print(track.track_title, "-", track.artist_title)
137
+ print(track.track_image_url)
138
+
139
+ playlist = get_playlist("https://open.spotify.com/playlist/5YOevUTnavVClJ0hAslu0N")
140
+ print(playlist.title)
141
+ print("Tracks:", len(playlist.track_urls))
142
+ print(playlist.track_urls[:5])
143
+ ```
144
+
145
+ ## **As a CLI**
146
+
147
+ After installation, you can use either:
148
+
149
+ - `syncify ...` (console script), or
150
+ - `python -m syncify ...` (module execution)
151
+
152
+ ```bash
153
+ # Auto-detect URL type (track or playlist)
154
+ syncify https://open.spotify.com/track/5nJ4Zzqc2UjwSaIcv7bGjx
155
+ syncify https://open.spotify.com/playlist/5YOevUTnavVClJ0hAslu0N
156
+
157
+ # Explicit type
158
+ syncify --track https://open.spotify.com/track/...
159
+ syncify --playlist https://open.spotify.com/playlist/...
160
+
161
+ # Multiple URLs (mixed types supported)
162
+ syncify <url1> <url2> <url3>
163
+ ```
164
+
165
+ CLI flags:
166
+
167
+ ```bash
168
+ syncify --track <URL>
169
+ syncify --playlist <URL>
170
+ syncify <URL> [URL ...]
171
+ ```
172
+
173
+ ## πŸ“‘ API Reference
174
+
175
+ ### `get_track(url: str) -> TrackDetails`
176
+
177
+ Fetch metadata for a Spotify track URL.
178
+
179
+ | Field | Type | Description |
180
+ |---|---:|---|
181
+ | `spotify_url` | `str` | Original Spotify URL |
182
+ | `track_id` | `str` | Spotify track ID |
183
+ | `track_title` | `str` | Song title |
184
+ | `artist_title` | `str` | Artist name |
185
+ | `track_image_url` | `str` | Cover image URL |
186
+
187
+ ### `get_playlist(url: str) -> PlaylistDetails`
188
+
189
+ Fetch metadata for a Spotify playlist URL.
190
+
191
+ | Field | Type | Description |
192
+ |---|---:|---|
193
+ | `playlist_url` | `str` | Original Spotify URL |
194
+ | `playlist_id` | `str` | Spotify playlist ID |
195
+ | `title` | `str` | Playlist title |
196
+ | `playlist_image_url` | `str` | Cover image URL |
197
+ | `track_urls` | `list[str]` | Track URLs in the playlist |
198
+
199
+ ## πŸ“‚ Project Structure
200
+
201
+ ```text
202
+ Syncify/
203
+ β”œβ”€ syncify/
204
+ β”‚ β”œβ”€ __init__.py # Public API exports
205
+ β”‚ β”œβ”€ __main__.py # CLI: `python -m syncify` / `syncify`
206
+ β”‚ └─ spotify/
207
+ β”‚ β”œβ”€ Spotify_track_info.py
208
+ β”‚ β”œβ”€ Spotify_playlist_info.py
209
+ β”‚ β”œβ”€ utils.py
210
+ β”‚ └─ __init__.py
211
+ β”œβ”€ main.py # Convenience script wrapper
212
+ β”œβ”€ pyproject.toml # Modern packaging + dependencies
213
+ β”œβ”€ setup.py # Legacy packaging (mirrors pyproject)
214
+ β”œβ”€ requirements.txt # Dev-friendly requirements list
215
+ └─ README.md
216
+ ```
217
+
218
+ ## πŸ§ͺ Development
219
+
220
+ ```bash
221
+ git clone https://github.com/adelelawady/Syncify.git
222
+ cd Syncify
223
+ python -m venv .venv
224
+
225
+ # Windows PowerShell
226
+ .venv\Scripts\Activate.ps1
227
+
228
+ pip install -e ".[dev]"
229
+
230
+ # Run the CLI against a URL
231
+ python -m syncify https://open.spotify.com/track/<id>
232
+ ```
233
+
234
+ Suggested checks:
235
+
236
+ ```bash
237
+ python -c "from syncify import get_track; print(get_track('https://open.spotify.com/track/<id>').track_title)"
238
+ ```
239
+
240
+ ## 🀝 Contributing
241
+
242
+ Contributions are welcome!
243
+
244
+ - **Bugs/requests**: open an issue with a minimal repro (URL + expected vs actual output)
245
+ - **PRs**:
246
+ - Keep changes focused and include a clear description
247
+ - Prefer small, well-scoped improvements to selectors and parsing logic
248
+ - Avoid committing local artifacts (`.venv/`, `build/`, `syncify.egg-info/`)
249
+
250
+ If you’re adding new scraping logic, please include:
251
+ - A sample Spotify URL (track/playlist) that the change targets
252
+ - A note about which DOM selectors were relied on and why
253
+
254
+ ## πŸ“œ License
255
+
256
+ **MIT** (as declared in package metadata).
257
+
258
+ > Tip: consider adding a top-level `LICENSE` file so GitHub can display the license automatically.
259
+
260
+ ## ⭐ Support
261
+
262
+ If you find Syncify useful, please **star** the repo β€” it helps others discover the project and motivates continued maintenance.
@@ -0,0 +1,11 @@
1
+ syncify/__init__.py,sha256=yVpPagyNWzPs4LqVh2oOUd7W2hX-drG2ACjQmB6Oj9g,295
2
+ syncify/__main__.py,sha256=j99TDk1bQiWrjELIwxlIjD1fUpaCW5WY2P4WOyP4jYA,2785
3
+ syncify/spotify/Spotify_playlist_info.py,sha256=MhLB_-6f57x-kipvwmOwHnxbjfPHHUxFYgJcTL4doQk,11542
4
+ syncify/spotify/Spotify_track_info.py,sha256=yIQ1npWmQIOcZgTkG4HouuJu1xc_OhIsIvIaD8FrXZA,6595
5
+ syncify/spotify/__init__.py,sha256=UdIQJpYuCJGH-wMMRJwk91HCEgQwiJNQYTvtRxQbu8U,27
6
+ syncify/spotify/utils.py,sha256=rvmcogcNjBohKEHBj9UHvkz3kMt2kJxm-GDj4HE7XAg,1111
7
+ syncify_py-1.0.0.dist-info/METADATA,sha256=xHzZIODzzSIQeIvSacFeomvFdJThtg61gF1MjQkqaT0,8693
8
+ syncify_py-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ syncify_py-1.0.0.dist-info/entry_points.txt,sha256=r0S2JvDU0KL-EwrqSPv1alvl6cdxoIzPPbUDoafFds8,50
10
+ syncify_py-1.0.0.dist-info/top_level.txt,sha256=m-sjI5MAW-8H2dXkFLbn2RdKfEesrKYlwq7GHONcZew,8
11
+ syncify_py-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ syncify = syncify.__main__:main
@@ -0,0 +1 @@
1
+ syncify