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 +6 -0
- syncify/__main__.py +79 -0
- syncify/spotify/Spotify_playlist_info.py +299 -0
- syncify/spotify/Spotify_track_info.py +177 -0
- syncify/spotify/__init__.py +1 -0
- syncify/spotify/utils.py +36 -0
- syncify_py-1.0.0.dist-info/METADATA +262 -0
- syncify_py-1.0.0.dist-info/RECORD +11 -0
- syncify_py-1.0.0.dist-info/WHEEL +5 -0
- syncify_py-1.0.0.dist-info/entry_points.txt +2 -0
- syncify_py-1.0.0.dist-info/top_level.txt +1 -0
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
|
syncify/spotify/utils.py
ADDED
|
@@ -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
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
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 @@
|
|
|
1
|
+
syncify
|