DiscogsDataProcessorCLI 1.5.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.
- discogs/__init__.py +0 -0
- discogs/chunker.py +95 -0
- discogs/config.py +75 -0
- discogs/converter.py +190 -0
- discogs/deleter.py +51 -0
- discogs/downloader.py +120 -0
- discogs/extractor.py +92 -0
- discogs/main.py +170 -0
- discogs/scraper.py +125 -0
- discogs/selector.py +197 -0
- discogs/utils.py +96 -0
- discogsdataprocessorcli-1.5.0.dist-info/METADATA +119 -0
- discogsdataprocessorcli-1.5.0.dist-info/RECORD +16 -0
- discogsdataprocessorcli-1.5.0.dist-info/WHEEL +5 -0
- discogsdataprocessorcli-1.5.0.dist-info/entry_points.txt +2 -0
- discogsdataprocessorcli-1.5.0.dist-info/top_level.txt +1 -0
discogs/main.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# discogs/main.py
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from discogs.selector import show_welcome, display_status_table, select_indices
|
|
5
|
+
from discogs.scraper import get_latest_files
|
|
6
|
+
from discogs.downloader import download_files_threaded
|
|
7
|
+
from discogs.extractor import extract_gz_files
|
|
8
|
+
from discogs.converter import convert_xml_to_csv
|
|
9
|
+
from discogs.config import get_download_dir
|
|
10
|
+
from discogs.utils import open_folder
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
# Initialize CLI app with help text
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="š¦ Discogs CLI - Download, extract, and convert Discogs data dumps.",
|
|
18
|
+
invoke_without_command=True
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
@app.command(help="One-click pipeline: Fetch latest files, download, extract, and convert to CSV.")
|
|
24
|
+
def run():
|
|
25
|
+
"""
|
|
26
|
+
Full automated pipeline: shows welcome screen, fetches files,
|
|
27
|
+
lets user choose which ones to download, then downloads, extracts,
|
|
28
|
+
and converts them to CSV.
|
|
29
|
+
"""
|
|
30
|
+
show_welcome()
|
|
31
|
+
download_dir = get_download_dir()
|
|
32
|
+
|
|
33
|
+
typer.echo("\U0001F50D Fetching available Discogs files...")
|
|
34
|
+
df = get_latest_files()
|
|
35
|
+
|
|
36
|
+
if df.empty:
|
|
37
|
+
typer.echo("No data found.")
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
|
|
40
|
+
display_status_table(df, download_dir)
|
|
41
|
+
indices = select_indices(df)
|
|
42
|
+
|
|
43
|
+
if not indices:
|
|
44
|
+
typer.echo("No selection made.")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
start = time.time()
|
|
48
|
+
|
|
49
|
+
downloaded = download_files_threaded(df, indices, download_dir)
|
|
50
|
+
extracted = extract_gz_files(downloaded)
|
|
51
|
+
|
|
52
|
+
for xml_file in extracted:
|
|
53
|
+
content_type = xml_file.stem.split("_")[-1]
|
|
54
|
+
convert_xml_to_csv(xml_file, content_type)
|
|
55
|
+
|
|
56
|
+
duration = time.time() - start
|
|
57
|
+
typer.secho(f"\nā
Done in {duration:.1f} seconds!", fg="green")
|
|
58
|
+
open_folder(download_dir)
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
@app.command()
|
|
62
|
+
def download():
|
|
63
|
+
"""
|
|
64
|
+
Download selected Discogs data files only (no extract or convert).
|
|
65
|
+
"""
|
|
66
|
+
download_dir = get_download_dir()
|
|
67
|
+
typer.echo("\U0001F50D Fetching available Discogs files...")
|
|
68
|
+
df = get_latest_files()
|
|
69
|
+
|
|
70
|
+
if df.empty:
|
|
71
|
+
typer.echo("No data found.")
|
|
72
|
+
raise typer.Exit()
|
|
73
|
+
|
|
74
|
+
display_status_table(df, download_dir)
|
|
75
|
+
indices = select_indices(df)
|
|
76
|
+
if not indices:
|
|
77
|
+
typer.echo("No files selected.")
|
|
78
|
+
raise typer.Exit()
|
|
79
|
+
|
|
80
|
+
download_files_threaded(df, indices, download_dir)
|
|
81
|
+
|
|
82
|
+
open_folder(download_dir)
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def convert():
|
|
86
|
+
"""Convert extracted XML files to CSV (interactive mode)."""
|
|
87
|
+
from discogs.converter import convert_interactively
|
|
88
|
+
convert_interactively()
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def extract():
|
|
92
|
+
"""Extract downloaded .gz files (interactive mode)."""
|
|
93
|
+
from discogs.extractor import extract_interactively
|
|
94
|
+
extract_interactively()
|
|
95
|
+
|
|
96
|
+
@app.command("delete")
|
|
97
|
+
def delete(all: bool = typer.Option(False, "--all", help="Delete all downloaded, extracted and converted files.")):
|
|
98
|
+
"""
|
|
99
|
+
Deletes selected or all downloaded, extracted, and converted files.
|
|
100
|
+
"""
|
|
101
|
+
download_dir = get_download_dir()
|
|
102
|
+
df = get_latest_files()
|
|
103
|
+
|
|
104
|
+
if df.empty:
|
|
105
|
+
console.print("[red]No files found.[/red]")
|
|
106
|
+
raise typer.Exit()
|
|
107
|
+
|
|
108
|
+
display_status_table(df, download_dir)
|
|
109
|
+
|
|
110
|
+
# If --all is passed, select all files
|
|
111
|
+
selected = list(range(len(df))) if all else select_indices(df, allow_all=True)
|
|
112
|
+
|
|
113
|
+
if not selected:
|
|
114
|
+
console.print("[yellow]No files selected.[/yellow]")
|
|
115
|
+
raise typer.Exit()
|
|
116
|
+
|
|
117
|
+
for i in selected:
|
|
118
|
+
row = df.iloc[i]
|
|
119
|
+
year_month = row["month"]
|
|
120
|
+
filename = Path(row["url"]).name
|
|
121
|
+
data_dir = download_dir / "Datasets" / year_month
|
|
122
|
+
|
|
123
|
+
gz_file = data_dir / filename
|
|
124
|
+
xml_file = gz_file.with_suffix("")
|
|
125
|
+
csv_file = xml_file.with_suffix(".csv")
|
|
126
|
+
|
|
127
|
+
for file in [gz_file, xml_file, csv_file]:
|
|
128
|
+
if file.exists():
|
|
129
|
+
try:
|
|
130
|
+
file.unlink()
|
|
131
|
+
console.print(f"[green]ā Deleted:[/] {file.name}")
|
|
132
|
+
except Exception as e:
|
|
133
|
+
console.print(f"[red]ā Failed to delete {file.name}:[/] {e}")
|
|
134
|
+
else:
|
|
135
|
+
console.print(f"[dim]⢠Not found:[/] {file.name}")
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def show():
|
|
139
|
+
"""
|
|
140
|
+
Displays the list of available Discogs dump files.
|
|
141
|
+
"""
|
|
142
|
+
from discogs.selector import show_welcome
|
|
143
|
+
from discogs.scraper import get_latest_files
|
|
144
|
+
from discogs.config import get_download_dir
|
|
145
|
+
|
|
146
|
+
show_welcome()
|
|
147
|
+
df = get_latest_files()
|
|
148
|
+
display_status_table(df, get_download_dir())
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def config():
|
|
152
|
+
"""Launches the download folder configuration prompt."""
|
|
153
|
+
from discogs.config import set_download_dir
|
|
154
|
+
set_download_dir()
|
|
155
|
+
|
|
156
|
+
@app.callback()
|
|
157
|
+
def main(ctx: typer.Context):
|
|
158
|
+
"""
|
|
159
|
+
If no subcommand is provided, defaults to running the `run` pipeline.
|
|
160
|
+
"""
|
|
161
|
+
if ctx.invoked_subcommand is None:
|
|
162
|
+
run()
|
|
163
|
+
|
|
164
|
+
# Automatically run the `run` pipeline if no arguments are provided
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
import sys
|
|
167
|
+
if len(sys.argv) == 1:
|
|
168
|
+
app(prog_name="discogs", args=["run"])
|
|
169
|
+
else:
|
|
170
|
+
app()
|
discogs/scraper.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
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
|
discogs/selector.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# discogs/selector.py
|
|
2
|
+
|
|
3
|
+
from rich.prompt import Prompt
|
|
4
|
+
from typing import List
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.markdown import Markdown
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from discogs.utils import human_readable_size
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
def display_table(df: pd.DataFrame) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Displays a Rich-formatted table of available Discogs files.
|
|
18
|
+
Shows basic info: index, month, content type, file size, and URL.
|
|
19
|
+
"""
|
|
20
|
+
table = Table(title="Available Discogs Files", show_lines=True)
|
|
21
|
+
|
|
22
|
+
table.add_column("No", style="cyan", justify="right")
|
|
23
|
+
table.add_column("Month", style="magenta")
|
|
24
|
+
table.add_column("Type", style="green")
|
|
25
|
+
table.add_column("Size (MB)", justify="right")
|
|
26
|
+
table.add_column("URL", style="dim", overflow="fold")
|
|
27
|
+
|
|
28
|
+
for i, row in df.iterrows():
|
|
29
|
+
size_mb = f"{row['size_bytes'] / (1024 ** 2):.2f}"
|
|
30
|
+
table.add_row(
|
|
31
|
+
str(i + 1),
|
|
32
|
+
row["month"],
|
|
33
|
+
row["content"],
|
|
34
|
+
size_mb,
|
|
35
|
+
row["url"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console.print(table)
|
|
39
|
+
|
|
40
|
+
def select_indices(df: pd.DataFrame, allow_all: bool = False) -> List[int]:
|
|
41
|
+
"""
|
|
42
|
+
Prompts user to select files by number (comma-separated list or 'all' if allowed).
|
|
43
|
+
Returns a list of selected row indices.
|
|
44
|
+
"""
|
|
45
|
+
while True:
|
|
46
|
+
selection = Prompt.ask(
|
|
47
|
+
"[bold green]Select file(s) by number (comma-separated)[/]",
|
|
48
|
+
default="1"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if allow_all and selection.strip().lower() == "all":
|
|
52
|
+
return list(range(len(df)))
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
selected = [int(x.strip()) - 1 for x in selection.split(",")]
|
|
56
|
+
if all(0 <= i < len(df) for i in selected):
|
|
57
|
+
return selected
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError
|
|
60
|
+
except ValueError:
|
|
61
|
+
console.print("[red]Invalid selection. Try again.[/red]")
|
|
62
|
+
|
|
63
|
+
def select_files(df: pd.DataFrame) -> List[int]:
|
|
64
|
+
"""
|
|
65
|
+
Allows user to select files using basic printed list.
|
|
66
|
+
Returns selected row indices.
|
|
67
|
+
"""
|
|
68
|
+
if df.empty:
|
|
69
|
+
console.print("[red]No files to select.[/red]")
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
for i, row in df.iterrows():
|
|
73
|
+
size_mb = f"{row['size_bytes'] / (1024 ** 2):.2f} MB"
|
|
74
|
+
console.print(f"[{i + 1}] {row['month']} | {row['content']} | {size_mb}")
|
|
75
|
+
|
|
76
|
+
while True:
|
|
77
|
+
selection = Prompt.ask("Select file(s) by number (comma-separated)", default="1")
|
|
78
|
+
try:
|
|
79
|
+
indices = [int(x.strip()) - 1 for x in selection.split(",")]
|
|
80
|
+
if all(0 <= i < len(df) for i in indices):
|
|
81
|
+
return indices
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
console.print("[red]Invalid selection. Try again.[/red]")
|
|
86
|
+
|
|
87
|
+
def display_status_table(df, download_dir: Path):
|
|
88
|
+
"""
|
|
89
|
+
Displays the full download/extract/convert status of all files in a table.
|
|
90
|
+
Includes ā/ā markers for each status column.
|
|
91
|
+
"""
|
|
92
|
+
table = Table(title="Available Discogs Files", show_lines=True)
|
|
93
|
+
table.add_column("No", justify="right", style="cyan", no_wrap=True)
|
|
94
|
+
table.add_column("Month", style="magenta")
|
|
95
|
+
table.add_column("Type", style="yellow")
|
|
96
|
+
table.add_column("Size", justify="right")
|
|
97
|
+
table.add_column("Downloaded", justify="center")
|
|
98
|
+
table.add_column("Extracted", justify="center")
|
|
99
|
+
table.add_column("Converted", justify="center")
|
|
100
|
+
|
|
101
|
+
for idx, row in df.iterrows():
|
|
102
|
+
filename = Path(row["url"]).name
|
|
103
|
+
year_month = row["month"]
|
|
104
|
+
data_dir = download_dir / "Datasets" / year_month
|
|
105
|
+
gz_path = data_dir / filename
|
|
106
|
+
xml_path = gz_path.with_suffix("")
|
|
107
|
+
csv_path = xml_path.with_suffix(".csv")
|
|
108
|
+
|
|
109
|
+
is_downloaded = gz_path.exists()
|
|
110
|
+
is_extracted = xml_path.exists()
|
|
111
|
+
is_converted = csv_path.exists()
|
|
112
|
+
|
|
113
|
+
check = lambda b: "[green]ā[/green]" if b else "[red]ā[/red]"
|
|
114
|
+
|
|
115
|
+
table.add_row(
|
|
116
|
+
str(idx + 1),
|
|
117
|
+
row["month"],
|
|
118
|
+
row["content"],
|
|
119
|
+
human_readable_size(row["size_bytes"]),
|
|
120
|
+
check(is_downloaded),
|
|
121
|
+
check(is_extracted),
|
|
122
|
+
check(is_converted),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
console.print(table)
|
|
126
|
+
|
|
127
|
+
def show_welcome():
|
|
128
|
+
"""
|
|
129
|
+
Displays the ASCII welcome screen with a summary of available commands and features.
|
|
130
|
+
"""
|
|
131
|
+
ascii_logo = r"""
|
|
132
|
+
āāāāāāā āāāāāāāāāāā āāāāāāā āāāāāāā āāāāāāā āāāāāāāā
|
|
133
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāā
|
|
134
|
+
āāā āāāāāāāāāāāāāāāāā āāā āāāāāā āāāāāāāāāāāā
|
|
135
|
+
āāā āāāāāāāāāāāāāāāāā āāā āāāāāā āāāāāāāāāāā
|
|
136
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
137
|
+
āāāāāāā āāāāāāāāāāā āāāāāāā āāāāāāā āāāāāāā āāāāāāāā
|
|
138
|
+
|
|
139
|
+
āāāāāāā āāāāāā āāāāāāāāā āāāāāā
|
|
140
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
141
|
+
āāā āāāāāāāāāāā āāā āāāāāāāā
|
|
142
|
+
āāā āāāāāāāāāāā āāā āāāāāāāā
|
|
143
|
+
āāāāāāāāāāā āāā āāā āāā āāā
|
|
144
|
+
āāāāāāā āāā āāā āāā āāā āāā
|
|
145
|
+
|
|
146
|
+
āāāāāāā āāāāāāā āāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāā āāāāāāā
|
|
147
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
148
|
+
āāāāāāāāāāāāāāāāāāā āāāāāā āāāāāā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāā
|
|
149
|
+
āāāāāāā āāāāāāāāāāā āāāāāā āāāāāā āāāāāāāāāāāāāāāāāāā āāāāāāāāāāā
|
|
150
|
+
āāā āāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāā
|
|
151
|
+
āāā āāā āāā āāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāā āāā āāā
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
console.print(Panel.fit(
|
|
155
|
+
ascii_logo,
|
|
156
|
+
title="Discogs Data Processor CLI",
|
|
157
|
+
subtitle="by ofurkancoban",
|
|
158
|
+
style="bold cyan"
|
|
159
|
+
))
|
|
160
|
+
|
|
161
|
+
md = Markdown("""
|
|
162
|
+
Welcome to the **Discogs CLI**!
|
|
163
|
+
|
|
164
|
+
This tool allows you to:
|
|
165
|
+
- š§ Scrape the latest data dump list from Discogs
|
|
166
|
+
- ā¬ļø Download selected files
|
|
167
|
+
- š¦ Extract `.gz` files
|
|
168
|
+
- āļø Chunk large XML into smaller files
|
|
169
|
+
- š Convert everything into tidy CSV
|
|
170
|
+
- š Delete downloaded/extracted/converted files
|
|
171
|
+
- āļø Configure your download folder
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
**Available Commands:**
|
|
176
|
+
|
|
177
|
+
- `python -m discogs.main run` ā Full auto mode (download ā extract ā convert)
|
|
178
|
+
- `python -m discogs.main show` ā Display available Discogs files
|
|
179
|
+
- `python -m discogs.main download` ā Download selected files
|
|
180
|
+
- `python -m discogs.main extract` ā Extract previously downloaded `.gz` files
|
|
181
|
+
- `python -m discogs.main convert` ā Convert extracted `.xml` files to `.csv`
|
|
182
|
+
- `python -m discogs.main delete` ā Delete files by selection (or `--all`)
|
|
183
|
+
- `python -m discogs.main config` ā Set or change your download folder
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
**Connect with me:**
|
|
188
|
+
|
|
189
|
+
- š GitHub: [github.com/ofurkancoban](https://github.com/ofurkancoban)
|
|
190
|
+
- š¼ LinkedIn: [linkedin.com/in/ofurkancoban](https://linkedin.com/in/ofurkancoban)
|
|
191
|
+
- š Kaggle: [kaggle.com/ofurkancoban](https://www.kaggle.com/ofurkancoban)
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
""")
|
|
196
|
+
|
|
197
|
+
console.print(md)
|
discogs/utils.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# discogs/utils.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from rich.prompt import Prompt
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
import subprocess
|
|
7
|
+
import platform
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
CONFIG_PATH = Path.home() / "Downloads" / "Discogs" / ".discogs_config.json"
|
|
13
|
+
DEFAULT_DOWNLOAD_PATH = Path.home() / "Downloads" / "Discogs"
|
|
14
|
+
|
|
15
|
+
def load_config() -> dict:
|
|
16
|
+
"""
|
|
17
|
+
Loads the config JSON from disk.
|
|
18
|
+
Returns default config if file doesn't exist or can't be read.
|
|
19
|
+
"""
|
|
20
|
+
if CONFIG_PATH.exists():
|
|
21
|
+
try:
|
|
22
|
+
with CONFIG_PATH.open("r") as f:
|
|
23
|
+
return json.load(f)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
console.print(f"[red]ā Error reading config:[/] {e}")
|
|
26
|
+
return {"download_dir": str(DEFAULT_DOWNLOAD_PATH)}
|
|
27
|
+
|
|
28
|
+
def save_config(config: dict):
|
|
29
|
+
"""
|
|
30
|
+
Saves the provided config dictionary as JSON to disk.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
with CONFIG_PATH.open("w") as f:
|
|
34
|
+
json.dump(config, f, indent=2)
|
|
35
|
+
console.print(f"[green]ā Config saved:[/] {CONFIG_PATH}")
|
|
36
|
+
except Exception as e:
|
|
37
|
+
console.print(f"[red]ā Failed to save config:[/] {e}")
|
|
38
|
+
|
|
39
|
+
def get_download_dir() -> Path:
|
|
40
|
+
"""
|
|
41
|
+
Returns the current download directory from config.
|
|
42
|
+
Falls back to default if not configured.
|
|
43
|
+
"""
|
|
44
|
+
config = load_config()
|
|
45
|
+
return Path(config.get("download_dir", str(DEFAULT_DOWNLOAD_PATH)))
|
|
46
|
+
|
|
47
|
+
def open_folder(path: Path):
|
|
48
|
+
"""
|
|
49
|
+
Opens the given folder path using the system's default file explorer.
|
|
50
|
+
Supports macOS, Windows, and Linux.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
if platform.system() == "Darwin": # macOS
|
|
54
|
+
subprocess.run(["open", str(path)])
|
|
55
|
+
elif platform.system() == "Windows":
|
|
56
|
+
subprocess.run(["explorer", str(path)])
|
|
57
|
+
else: # Linux (assumes xdg-open is available)
|
|
58
|
+
subprocess.run(["xdg-open", str(path)])
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"Error opening folder: {e}")
|
|
61
|
+
|
|
62
|
+
def human_readable_size(size_bytes: int) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Converts a file size in bytes into a human-readable string (KB, MB, GB, etc).
|
|
65
|
+
"""
|
|
66
|
+
if size_bytes == 0:
|
|
67
|
+
return "0 B"
|
|
68
|
+
|
|
69
|
+
size_name = ("B", "KB", "MB", "GB", "TB")
|
|
70
|
+
i = 0
|
|
71
|
+
double_size = float(size_bytes)
|
|
72
|
+
while double_size >= 1024 and i < len(size_name) - 1:
|
|
73
|
+
double_size /= 1024
|
|
74
|
+
i += 1
|
|
75
|
+
return f"{double_size:.2f} {size_name[i]}"
|
|
76
|
+
|
|
77
|
+
def set_download_dir():
|
|
78
|
+
"""
|
|
79
|
+
Prompts the user to enter a new download folder and updates the config.
|
|
80
|
+
Creates the folder if it doesn't exist.
|
|
81
|
+
"""
|
|
82
|
+
current = get_download_dir()
|
|
83
|
+
new_path = Prompt.ask("Download folder", default=str(current)).strip()
|
|
84
|
+
path = Path(new_path).expanduser()
|
|
85
|
+
|
|
86
|
+
if not path.exists():
|
|
87
|
+
try:
|
|
88
|
+
path.mkdir(parents=True)
|
|
89
|
+
console.print(f"[green]ā Created directory:[/] {path}")
|
|
90
|
+
except Exception as e:
|
|
91
|
+
console.print(f"[red]Failed to create directory:[/] {e}")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
config = load_config()
|
|
95
|
+
config["download_dir"] = str(path)
|
|
96
|
+
save_config(config)
|