DiscogsDataProcessorCLI 1.5.9__tar.gz → 1.6.1__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 (22) hide show
  1. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/PKG-INFO +2 -1
  2. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/requires.txt +1 -0
  3. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/PKG-INFO +2 -1
  4. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/deleter.py +1 -1
  5. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/downloader.py +49 -24
  6. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/main.py +1 -2
  7. discogsdataprocessorcli-1.6.1/discogs/scraper.py +156 -0
  8. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/selector.py +2 -2
  9. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/setup.cfg +2 -1
  10. discogsdataprocessorcli-1.5.9/discogs/scraper.py +0 -125
  11. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/SOURCES.txt +0 -0
  12. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/dependency_links.txt +0 -0
  13. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/entry_points.txt +0 -0
  14. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/top_level.txt +0 -0
  15. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/README.md +0 -0
  16. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/__init__.py +0 -0
  17. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/chunker.py +0 -0
  18. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/config.py +0 -0
  19. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/converter.py +0 -0
  20. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/extractor.py +0 -0
  21. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/discogs/utils.py +0 -0
  22. {discogsdataprocessorcli-1.5.9 → discogsdataprocessorcli-1.6.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DiscogsDataProcessorCLI
3
- Version: 1.5.9
3
+ Version: 1.6.1
4
4
  Summary: A CLI to download, extract and convert Discogs data dumps
5
5
  Home-page: https://github.com/ofurkancoban/DiscogsCLI
6
6
  Author: Furkan Çoban
@@ -14,6 +14,7 @@ Description-Content-Type: text/markdown
14
14
  Requires-Dist: rich
15
15
  Requires-Dist: pandas
16
16
  Requires-Dist: typer
17
+ Requires-Dist: requests
17
18
 
18
19
  # 🎧 Discogs CLI — Data Processor Tool 💿
19
20
  <p align="center">
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DiscogsDataProcessorCLI
3
- Version: 1.5.9
3
+ Version: 1.6.1
4
4
  Summary: A CLI to download, extract and convert Discogs data dumps
5
5
  Home-page: https://github.com/ofurkancoban/DiscogsCLI
6
6
  Author: Furkan Çoban
@@ -14,6 +14,7 @@ Description-Content-Type: text/markdown
14
14
  Requires-Dist: rich
15
15
  Requires-Dist: pandas
16
16
  Requires-Dist: typer
17
+ Requires-Dist: requests
17
18
 
18
19
  # 🎧 Discogs CLI — Data Processor Tool 💿
19
20
  <p align="center">
@@ -29,7 +29,7 @@ def delete_files():
29
29
 
30
30
  for i in selected:
31
31
  row = df.iloc[i]
32
- filename = Path(row["url"]).name
32
+ filename = row["filename"]
33
33
  year_month = row["month"]
34
34
  data_dir = download_dir / "Datasets" / year_month
35
35
 
@@ -4,7 +4,6 @@ import time
4
4
  import requests
5
5
  from time import sleep
6
6
  from pathlib import Path
7
- from urllib.parse import urlparse
8
7
  from rich.console import Console
9
8
  from rich.progress import (
10
9
  Progress, BarColumn, DownloadColumn, TransferSpeedColumn,
@@ -15,27 +14,50 @@ from datetime import datetime
15
14
 
16
15
  console = Console()
17
16
 
17
+ # Discogs' data listing host rate-limits aggressively, so keep concurrency low
18
+ # and stagger requests to avoid tripping it.
19
+ MAX_WORKERS = 3
20
+ STAGGER_SECONDS = 1.0
21
+ REQUEST_HEADERS = {
22
+ "User-Agent": "Mozilla/5.0 (compatible; DiscogsCLI/1.6; +https://github.com/ofurkancoban/DiscogsCLI)"
23
+ }
24
+
25
+
18
26
  def _download_file(url: str, target_path: Path, progress, task_id, retries: int = 5) -> Path:
19
27
  """
20
28
  Downloads a file with support for resume and retry.
21
29
  Updates a Rich progress bar during download.
22
30
  """
23
- headers = {}
24
- downloaded = 0
25
-
26
- # If file exists, resume from where it left off
27
- if target_path.exists():
28
- downloaded = target_path.stat().st_size
29
- headers["Range"] = f"bytes={downloaded}-"
30
-
31
- total_size = int(requests.head(url).headers.get("Content-Length", 0))
31
+ downloaded = target_path.stat().st_size if target_path.exists() else 0
32
+ if downloaded:
33
+ progress.update(task_id, completed=downloaded)
32
34
 
33
35
  for attempt in range(retries):
36
+ headers = dict(REQUEST_HEADERS)
37
+ if downloaded:
38
+ headers["Range"] = f"bytes={downloaded}-"
39
+
34
40
  try:
35
- with requests.get(url, headers=headers, stream=True, timeout=10) as response:
41
+ with requests.get(url, headers=headers, stream=True, timeout=30) as response:
42
+ if response.status_code == 429:
43
+ wait = int(response.headers.get("Retry-After", 30))
44
+ console.print(f"[yellow]⚠ Rate limited by server, waiting {wait}s...[/]")
45
+ sleep(wait)
46
+ continue
47
+
48
+ if response.status_code == 416:
49
+ # Requested range not satisfiable: file is already complete
50
+ return target_path
51
+
36
52
  response.raise_for_status()
37
53
 
38
- mode = "ab" if downloaded else "wb"
54
+ # Server may ignore the Range header and resend the whole file
55
+ resumed = downloaded > 0 and response.status_code == 206
56
+ if not resumed:
57
+ downloaded = 0
58
+ progress.update(task_id, completed=0)
59
+
60
+ mode = "ab" if resumed else "wb"
39
61
  with open(target_path, mode) as f:
40
62
  for chunk in response.iter_content(chunk_size=1024 * 64):
41
63
  if chunk:
@@ -48,17 +70,23 @@ def _download_file(url: str, target_path: Path, progress, task_id, retries: int
48
70
  except requests.RequestException as e:
49
71
  # Retry a few times if download fails
50
72
  if attempt < retries - 1:
51
- sleep(1.5)
73
+ sleep(3)
52
74
  continue
53
75
  else:
54
76
  raise RuntimeError(f"Download failed after {retries} retries: {e}")
55
77
 
78
+ raise RuntimeError(f"Download failed after {retries} retries due to rate limiting")
79
+
80
+
56
81
  def download_files_threaded(df, selected_indexes, download_dir: Path) -> list[Path]:
57
82
  """
58
83
  Downloads multiple files concurrently using threads.
59
84
  Displays a combined progress bar for all downloads.
60
85
  """
61
- urls = [df.iloc[i]["url"] for i in selected_indexes]
86
+ items = [
87
+ (row["url"], row["filename"], int(row["size_bytes"]))
88
+ for row in (df.iloc[i] for i in selected_indexes)
89
+ ]
62
90
  paths = []
63
91
  total_bytes = 0
64
92
  start_time = time.time()
@@ -75,12 +103,10 @@ def download_files_threaded(df, selected_indexes, download_dir: Path) -> list[Pa
75
103
  "•",
76
104
  TimeRemainingColumn(),
77
105
  ) as progress:
78
- with ThreadPoolExecutor(max_workers=8) as executor:
106
+ with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
79
107
  futures = []
80
108
 
81
- for url in urls:
82
- filename = Path(urlparse(url).path).name
83
-
109
+ for url, filename, size_bytes in items:
84
110
  # Extract date from filename and create target folder
85
111
  date_str = filename.split("_")[1]
86
112
  year_month = datetime.strptime(date_str, "%Y%m%d").strftime("%Y-%m")
@@ -89,17 +115,16 @@ def download_files_threaded(df, selected_indexes, download_dir: Path) -> list[Pa
89
115
  target_path = target_folder / filename
90
116
 
91
117
  # Skip already downloaded files
92
- if target_path.exists():
118
+ if target_path.exists() and (size_bytes == 0 or target_path.stat().st_size >= size_bytes):
93
119
  console.print(f"[yellow]⚠ Already downloaded:[/] {filename}")
94
120
  paths.append(target_path)
95
121
  continue
96
122
 
97
- # Prepare progress bar for this file
98
- total = int(requests.head(url).headers.get("Content-Length", 0))
99
- total_bytes += total
100
- task_id = progress.add_task("Downloading", filename=filename, total=total)
123
+ total_bytes += size_bytes
124
+ task_id = progress.add_task("Downloading", filename=filename, total=size_bytes)
101
125
  future = executor.submit(_download_file, url, target_path, progress, task_id)
102
126
  futures.append(future)
127
+ sleep(STAGGER_SECONDS) # avoid bursting requests against the rate limiter
103
128
 
104
129
  # Wait for all downloads to finish
105
130
  for future in as_completed(futures):
@@ -117,4 +142,4 @@ def download_files_threaded(df, selected_indexes, download_dir: Path) -> list[Pa
117
142
  console.print(f"[cyan]💾 Total size:[/] {size_mb:.1f} MB")
118
143
  console.print(f"[cyan]⏱ Duration:[/] {duration:.1f} seconds")
119
144
 
120
- return paths
145
+ return paths
@@ -57,7 +57,6 @@ def run():
57
57
  typer.secho(f"\n✅ Done in {duration:.1f} seconds!", fg="green")
58
58
  open_folder(download_dir)
59
59
 
60
- @app.command()
61
60
  @app.command()
62
61
  def download():
63
62
  """
@@ -117,7 +116,7 @@ def delete(all: bool = typer.Option(False, "--all", help="Delete all downloaded,
117
116
  for i in selected:
118
117
  row = df.iloc[i]
119
118
  year_month = row["month"]
120
- filename = Path(row["url"]).name
119
+ filename = row["filename"]
121
120
  data_dir = download_dir / "Datasets" / year_month
122
121
 
123
122
  gz_file = data_dir / filename
@@ -0,0 +1,156 @@
1
+ # discogs/scraper.py
2
+
3
+ import re
4
+ import requests
5
+ import pandas as pd
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from urllib.parse import quote, unquote
9
+ from discogs.config import get_download_dir
10
+
11
+ # Discogs replaced direct S3 bucket listing with this CDN-backed listing page.
12
+ BASE_URL = "https://data.discogs.com/"
13
+ DATA_PREFIX = "data/"
14
+
15
+ REQUEST_HEADERS = {
16
+ "User-Agent": "Mozilla/5.0 (compatible; DiscogsCLI/1.6; +https://github.com/ofurkancoban/DiscogsCLI)"
17
+ }
18
+
19
+ # Matches one listing row, e.g.:
20
+ # 2026-01-15 16:40:37 388 B <a href="?download=data%2F2026%2Fdiscogs_20260101_CHECKSUM.txt">discogs_20260101_CHECKSUM.txt</a>
21
+ # - <a href="?prefix=data%2F2026%2F">2026/</a>
22
+ _ROW_RE = re.compile(
23
+ r'(?:(?P<date>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s*)?'
24
+ r'(?P<size>[\d.]+\s?[KMGT]?B|-)\s+'
25
+ r'<a href="\?(?P<kind>download|prefix)=(?P<key>[^"]+)">(?P<name>[^<]+)</a>'
26
+ )
27
+
28
+ _SIZE_MULTIPLIERS = {"B": 1, "KB": 1024, "MB": 1024 ** 2, "GB": 1024 ** 3, "TB": 1024 ** 4}
29
+
30
+
31
+ def _parse_size(size_str: str) -> int:
32
+ """Converts a human-readable size string (e.g. "455.8 MB") to bytes."""
33
+ size_str = size_str.strip()
34
+ match = re.match(r"([\d.]+)\s*([KMGT]?B)", size_str)
35
+ if not match:
36
+ return 0
37
+ value, unit = match.groups()
38
+ return int(float(value) * _SIZE_MULTIPLIERS.get(unit, 1))
39
+
40
+
41
+ def _fetch_listing(prefix: str) -> str:
42
+ """Fetches the raw HTML listing page for a given key prefix."""
43
+ url = f"{BASE_URL}?prefix={quote(prefix, safe='')}"
44
+ r = requests.get(url, headers=REQUEST_HEADERS, timeout=30)
45
+ r.raise_for_status()
46
+ return r.text
47
+
48
+
49
+ def list_directories() -> list[str]:
50
+ """
51
+ Lists available yearly folders on the Discogs data listing page.
52
+ Example: data/2024/
53
+ """
54
+ html = _fetch_listing(DATA_PREFIX)
55
+
56
+ dirs = set()
57
+ for match in _ROW_RE.finditer(html):
58
+ if match.group("kind") != "prefix":
59
+ continue
60
+ key = unquote(match.group("key"))
61
+ if re.match(r"^data/\d{4}/$", key):
62
+ dirs.add(key)
63
+
64
+ return sorted(dirs)
65
+
66
+
67
+ def list_files(directory_prefix: str) -> pd.DataFrame:
68
+ """
69
+ Lists files in the specified year folder and extracts metadata like size,
70
+ last modified date, type (artists, labels, etc.), and generates their URLs.
71
+ """
72
+ html = _fetch_listing(directory_prefix)
73
+
74
+ data = []
75
+ for match in _ROW_RE.finditer(html):
76
+ if match.group("kind") != "download":
77
+ continue
78
+
79
+ key = unquote(match.group("key"))
80
+ filename = Path(key).name
81
+
82
+ ctype = "unknown"
83
+ lname = filename.lower()
84
+ if "artist" in lname:
85
+ ctype = "artists"
86
+ elif "label" in lname:
87
+ ctype = "labels"
88
+ elif "master" in lname:
89
+ ctype = "masters"
90
+ elif "release" in lname:
91
+ ctype = "releases"
92
+
93
+ # Filter only usable .gz files
94
+ if ctype != "unknown" and filename.endswith(".gz"):
95
+ month = get_month_from_key(filename)
96
+ data.append({
97
+ "key": key,
98
+ "size_bytes": _parse_size(match.group("size")),
99
+ "last_modified": match.group("date"),
100
+ "month": month,
101
+ "content": ctype,
102
+ "filename": filename,
103
+ "url": f"{BASE_URL}?download={quote(key, safe='')}",
104
+ })
105
+
106
+ df = pd.DataFrame(data)
107
+ if df.empty:
108
+ return df
109
+
110
+ # Add download/extracted/converted status columns
111
+ download_dir = get_download_dir()
112
+ df["downloaded"] = df["filename"].apply(
113
+ lambda fn: (download_dir / "Datasets" / get_month_from_key(fn) / fn).exists()
114
+ )
115
+ df["extracted"] = df["filename"].apply(
116
+ lambda fn: (download_dir / "Datasets" / get_month_from_key(fn) / fn.replace(".gz", "")).exists()
117
+ )
118
+ df["converted"] = df["filename"].apply(
119
+ lambda fn: (download_dir / "Datasets" / get_month_from_key(fn) / fn.replace(".gz", ".csv")).exists()
120
+ )
121
+
122
+ return df
123
+
124
+
125
+ def get_month_from_key(key: str) -> str:
126
+ """
127
+ Extracts the year and month from the Discogs filename.
128
+ Example: discogs_20240101_artist.gz → 2024-01
129
+ """
130
+ match = re.search(r"discogs_(\d{6})\d{2}", key)
131
+ if match:
132
+ try:
133
+ return datetime.strptime(match.group(1), "%Y%m").strftime("%Y-%m")
134
+ except Exception:
135
+ return ""
136
+ return ""
137
+
138
+
139
+ def get_latest_files() -> pd.DataFrame:
140
+ """
141
+ Fetches and returns a DataFrame with files from the most recent available folder.
142
+ """
143
+ dirs = list_directories()
144
+ if not dirs:
145
+ return pd.DataFrame()
146
+
147
+ latest_dir = dirs[-1]
148
+ df = list_files(latest_dir)
149
+
150
+ if df.empty:
151
+ return df
152
+
153
+ # Parse dates and sort by month and type
154
+ df["last_modified"] = pd.to_datetime(df["last_modified"])
155
+ df = df.sort_values(by=["month", "content"], ascending=[False, True]).reset_index(drop=True)
156
+ return df
@@ -99,7 +99,7 @@ def display_status_table(df, download_dir: Path):
99
99
  table.add_column("Converted", justify="center")
100
100
 
101
101
  for idx, row in df.iterrows():
102
- filename = Path(row["url"]).name
102
+ filename = row["filename"]
103
103
  year_month = row["month"]
104
104
  data_dir = download_dir / "Datasets" / year_month
105
105
  gz_path = data_dir / filename
@@ -153,7 +153,7 @@ def show_welcome():
153
153
 
154
154
  console.print(Panel.fit(
155
155
  ascii_logo,
156
- title="Discogs Data Processor CLI (v1.5)",
156
+ title="Discogs Data Processor CLI (v1.6.1)",
157
157
  subtitle="by ofurkancoban",
158
158
  style="bold cyan"
159
159
  ))
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = DiscogsDataProcessorCLI
3
- version = 1.5.9
3
+ version = 1.6.1
4
4
  author = Furkan Çoban
5
5
  author_email = ofurkancoban@gmail.com
6
6
  description = A CLI to download, extract and convert Discogs data dumps
@@ -21,6 +21,7 @@ install_requires =
21
21
  rich
22
22
  pandas
23
23
  typer
24
+ requests
24
25
 
25
26
  [options.entry_points]
26
27
  console_scripts =
@@ -1,125 +0,0 @@
1
- # discogs/scraper.py
2
-
3
- import re
4
- import requests
5
- import pandas as pd
6
- import xml.etree.ElementTree as ET
7
- from datetime import datetime
8
- from pathlib import Path
9
- from discogs.config import get_download_dir
10
-
11
- # Base URL of the Discogs S3 bucket
12
- S3_BASE_URL = "https://discogs-data-dumps.s3.us-west-2.amazonaws.com/"
13
- S3_PREFIX = "data/" # Prefix for data folders inside the bucket
14
-
15
- def list_directories() -> list[str]:
16
- """
17
- Lists available yearly folders on the Discogs S3 bucket.
18
- Example: data/2024/
19
- """
20
- url = f"{S3_BASE_URL}?prefix={S3_PREFIX}&delimiter=/"
21
- r = requests.get(url)
22
- r.raise_for_status()
23
-
24
- ns = "{http://s3.amazonaws.com/doc/2006-03-01/}"
25
- root = ET.fromstring(r.text)
26
-
27
- dirs = []
28
- for cp in root.findall(ns + 'CommonPrefixes'):
29
- p = cp.find(ns + 'Prefix').text
30
- if re.match(r"data/\d{4}/", p): # Match folders like "data/2023/"
31
- dirs.append(p)
32
-
33
- return sorted(dirs)
34
-
35
- def list_files(directory_prefix: str) -> pd.DataFrame:
36
- """
37
- Lists files in the specified S3 folder and extracts metadata like size,
38
- last modified date, type (artists, labels, etc.), and generates their URLs.
39
- """
40
- url = f"{S3_BASE_URL}?prefix={directory_prefix}"
41
- r = requests.get(url)
42
- r.raise_for_status()
43
-
44
- ns = "{http://s3.amazonaws.com/doc/2006-03-01/}"
45
- root = ET.fromstring(r.text)
46
-
47
- data = []
48
- for content in root.findall(ns + 'Contents'):
49
- key = content.find(ns + 'Key').text
50
- size = int(content.find(ns + 'Size').text)
51
- last_modified = content.find(ns + 'LastModified').text
52
-
53
- # Determine content type from filename
54
- ctype = "unknown"
55
- lname = key.lower()
56
- if "artist" in lname:
57
- ctype = "artists"
58
- elif "label" in lname:
59
- ctype = "labels"
60
- elif "master" in lname:
61
- ctype = "masters"
62
- elif "release" in lname:
63
- ctype = "releases"
64
-
65
- # Filter only usable .gz files
66
- if ctype != "unknown" and key.endswith(".gz"):
67
- month = get_month_from_key(key)
68
- filename = Path(key).name
69
- data.append({
70
- "key": key,
71
- "size_bytes": size,
72
- "last_modified": last_modified,
73
- "month": month,
74
- "content": ctype,
75
- "filename": filename,
76
- "url": S3_BASE_URL + key,
77
- })
78
-
79
- df = pd.DataFrame(data)
80
-
81
- # Add download/extracted/converted status columns
82
- download_dir = get_download_dir()
83
- df["downloaded"] = df["filename"].apply(
84
- lambda fn: (download_dir / "Datasets" / get_month_from_key(fn) / fn).exists()
85
- )
86
- df["extracted"] = df["filename"].apply(
87
- lambda fn: (download_dir / "Datasets" / get_month_from_key(fn) / fn.replace(".gz", "")).exists()
88
- )
89
- df["converted"] = df["filename"].apply(
90
- lambda fn: (download_dir / "Datasets" / get_month_from_key(fn) / fn.replace(".gz", ".csv")).exists()
91
- )
92
-
93
- return df
94
-
95
- def get_month_from_key(key: str) -> str:
96
- """
97
- Extracts the year and month from the Discogs filename.
98
- Example: discogs_20240101_artist.gz → 2024-01
99
- """
100
- match = re.search(r"discogs_(\d{6})\d{2}", key)
101
- if match:
102
- try:
103
- return datetime.strptime(match.group(1), "%Y%m").strftime("%Y-%m")
104
- except Exception:
105
- return ""
106
- return ""
107
-
108
- def get_latest_files() -> pd.DataFrame:
109
- """
110
- Fetches and returns a DataFrame with files from the most recent available S3 folder.
111
- """
112
- dirs = list_directories()
113
- if not dirs:
114
- return pd.DataFrame()
115
-
116
- latest_dir = dirs[-1]
117
- df = list_files(latest_dir)
118
-
119
- if df.empty:
120
- return df
121
-
122
- # Parse dates and sort by month and type
123
- df["last_modified"] = pd.to_datetime(df["last_modified"])
124
- df = df.sort_values(by=["month", "content"], ascending=[False, True]).reset_index(drop=True)
125
- return df