DiscogsDataProcessorCLI 1.6__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.
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/PKG-INFO +8 -3
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/requires.txt +1 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/PKG-INFO +8 -3
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/README.md +6 -2
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/deleter.py +1 -1
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/downloader.py +49 -24
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/main.py +1 -2
- discogsdataprocessorcli-1.6.1/discogs/scraper.py +156 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/selector.py +2 -2
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/setup.cfg +2 -1
- discogsdataprocessorcli-1.6/discogs/scraper.py +0 -125
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/SOURCES.txt +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/dependency_links.txt +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/entry_points.txt +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/DiscogsDataProcessorCLI.egg-info/top_level.txt +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/__init__.py +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/chunker.py +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/config.py +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/converter.py +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/extractor.py +0 -0
- {discogsdataprocessorcli-1.6 → discogsdataprocessorcli-1.6.1}/discogs/utils.py +0 -0
- {discogsdataprocessorcli-1.6 → 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.6
|
|
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,13 +14,18 @@ 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">
|
|
21
|
+
<img src="img/logo.png" alt="Discogs Logo" width="200"/>
|
|
22
|
+
</p>
|
|
20
23
|
|
|
21
24
|
A modern command-line tool to `download`, `extract`, and `convert` Discogs data dumps into structured CSV files.
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="img/preview.gif" />
|
|
28
|
+
</p>
|
|
24
29
|
|
|
25
30
|
---
|
|
26
31
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: DiscogsDataProcessorCLI
|
|
3
|
-
Version: 1.6
|
|
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,13 +14,18 @@ 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">
|
|
21
|
+
<img src="img/logo.png" alt="Discogs Logo" width="200"/>
|
|
22
|
+
</p>
|
|
20
23
|
|
|
21
24
|
A modern command-line tool to `download`, `extract`, and `convert` Discogs data dumps into structured CSV files.
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="img/preview.gif" />
|
|
28
|
+
</p>
|
|
24
29
|
|
|
25
30
|
---
|
|
26
31
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# 🎧 Discogs CLI — Data Processor Tool 💿
|
|
2
|
-
|
|
2
|
+
<p align="center">
|
|
3
|
+
<img src="img/logo.png" alt="Discogs Logo" width="200"/>
|
|
4
|
+
</p>
|
|
3
5
|
|
|
4
6
|
A modern command-line tool to `download`, `extract`, and `convert` Discogs data dumps into structured CSV files.
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<img src="img/preview.gif" />
|
|
10
|
+
</p>
|
|
7
11
|
|
|
8
12
|
---
|
|
9
13
|
|
|
@@ -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
|
-
|
|
24
|
-
downloaded
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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=
|
|
106
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
79
107
|
futures = []
|
|
80
108
|
|
|
81
|
-
for url in
|
|
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
|
-
|
|
98
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.6
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|