Skip to content

Commit 89aaca4

Browse files
Make Discogs lookups visible, stoppable and resumable
A 1400-track lookup is an hour of work, and it showed nothing until the very end: results were only applied when the whole run finished, and the cache was only written in the final block. Stopping or crashing threw away every lookup already paid for, and while it ran there was no way to tell it apart from a hang. Results now land in the Suggested column as they arrive, the status line reports position, matches so far and rough time remaining, and Stop ends the run keeping everything fetched. The cache is written every ten lookups, so a stopped run resumes instead of restarting. The estimate is also honest now: it counts only tracks that are not already cached, and excludes ones with no artist or title, which could never match and were previously costing 2.5 seconds each to ask about anyway. An optional Discogs token can be saved to move from ~25 to ~60 requests per minute. It is not required for anything. Also imports urllib.error explicitly, which the 429 backoff path depended on getting for free from urllib.request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 53072af commit 89aaca4

4 files changed

Lines changed: 137 additions & 24 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,14 @@ nothing to infer from, and it lands in `Unsorted`. The **Genres** tab is the way
110110
out: select a group and set it, or ask Discogs.
111111

112112
Discogs needs no API token. Its free tier is rate limited, so lookups are paced
113-
at 2.5s and cached on disk — re-running never asks twice. Queries are cleaned
113+
at 2.5s and cached on disk — re-running never asks twice. A free personal token
114+
from discogs.com/settings/developer raises the rate to about one per second;
115+
paste it into the Genres tab if you're doing a big batch.
116+
117+
A long run reports as it goes: results fill the Suggested column while it works,
118+
the status line shows how many are done and roughly how long is left, and Stop
119+
keeps everything already fetched. The cache is written throughout, so a stopped
120+
or crashed run resumes where it left off rather than starting over. Queries are cleaned
114121
first (`Track (Original Mix)_PN``Track`), which took the hit rate from 1-in-8
115122
to 6-in-10 on a real batch. Only artist and title are sent.
116123

sortero/app.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,14 +1288,35 @@ def build(self):
12881288
command=self.apply_manual).pack(side="left")
12891289
ttk.Button(row2, text="Look up selected on Discogs",
12901290
command=self.lookup).pack(side="left", padx=(16, 0))
1291+
self.stop_btn = ttk.Button(row2, text="Stop", command=self.stop_lookup,
1292+
state="disabled")
1293+
self.stop_btn.pack(side="left", padx=4)
12911294
ttk.Button(row2, text="Accept suggestions",
12921295
command=self.apply_suggested).pack(side="left", padx=6)
12931296

1297+
self.lookup_status = ttk.Label(self, text="", foreground="#1d5c3a",
1298+
font=("Helvetica", 12, "bold"))
1299+
self.lookup_status.pack(anchor="w", pady=(0, 4))
1300+
1301+
tok = ttk.Frame(self)
1302+
tok.pack(fill="x", pady=(0, 6))
1303+
ttk.Label(tok, text="Discogs token (optional)").pack(side="left")
1304+
self.token_var = tk.StringVar(value=settings.get("discogs_token") or "")
1305+
ttk.Entry(tok, textvariable=self.token_var, width=34,
1306+
show="•").pack(side="left", padx=6)
1307+
ttk.Button(tok, text="Save", command=self._save_token).pack(side="left")
1308+
ttk.Label(tok, foreground="#666",
1309+
text=" — works without one; a free token from discogs.com/settings/"
1310+
"developer makes lookups about 2.5x faster"
1311+
).pack(side="left")
1312+
12941313
f, self.tv = tree(self, ("Track", "Artist", "Genre", "Suggested", "Where"),
12951314
(300, 190, 150, 170, 200), height=15)
12961315
f.pack(fill="both", expand=True)
12971316
self.tv.bind("<<TreeviewSelect>>", lambda e: self._sync())
12981317
self.rows, self.suggested = [], {}
1318+
self._stop = threading.Event()
1319+
self._looking = False
12991320

13001321
def invalidate(self):
13011322
vocab = sorted({lab for _, lab in organize.GENRE_RULES} |
@@ -1380,30 +1401,70 @@ def done(res):
13801401

13811402
self.app.task.run(work, done, "Writing genres")
13821403

1404+
def _save_token(self):
1405+
settings.set("discogs_token", self.token_var.get().strip())
1406+
messagebox.showinfo(APP, "Saved. Lookups will run at the faster rate."
1407+
if self.token_var.get().strip() else
1408+
"Cleared. Lookups will use the slower anonymous rate.")
1409+
1410+
def stop_lookup(self):
1411+
self._stop.set()
1412+
self.lookup_status.configure(text="Stopping after the current track…")
1413+
1414+
def _tick(self):
1415+
"""Refresh the table while a lookup runs, so results appear as they land."""
1416+
if not self._looking:
1417+
return
1418+
self.refresh()
1419+
self.after(4000, self._tick)
1420+
13831421
def lookup(self):
1384-
recs = self._selected()
1422+
recs = [r for r in self._selected() if genres.worth_asking(r)]
1423+
skipped = len(self._selected()) - len(recs)
13851424
if not recs:
1386-
messagebox.showinfo(APP, "Select the tracks you want looked up.")
1425+
messagebox.showinfo(APP, "Select the tracks you want looked up."
1426+
+ (f"\n\n{skipped} have no artist or title to "
1427+
"search with — set those by hand instead."
1428+
if skipped else ""))
13871429
return
1388-
mins = max(1, round(len(recs) * genres.MIN_INTERVAL / 60))
1430+
cache = genres.load_cache()
1431+
fresh = [r for r in recs if genres.key_for(r) not in cache]
1432+
mins = genres.eta_minutes(len(fresh))
13891433
if not messagebox.askyesno(
13901434
APP, f"Look up {len(recs)} tracks on Discogs?\n\n"
1391-
f"Discogs rate-limits free use, so this is paced and takes "
1392-
f"roughly {mins} minute(s). Results are cached, so re-running "
1393-
"never asks twice.\n\nArtist and title are sent to Discogs; "
1394-
"nothing else leaves your machine."):
1435+
f"{len(recs) - len(fresh)} are already cached and cost nothing; "
1436+
f"{len(fresh)} need asking, which takes about {mins} minute(s) "
1437+
"because Discogs rate-limits free use.\n\n"
1438+
"Results appear in the Suggested column as they arrive, and you "
1439+
"can Stop at any point — everything fetched is kept."
1440+
+ (f"\n\n{skipped} selected tracks have no artist or title and "
1441+
"were left out." if skipped else "")
1442+
+ "\n\nOnly artist and title are sent to Discogs."):
13951443
return
13961444

1445+
self._stop.clear()
1446+
self._looking = True
1447+
self.stop_btn.configure(state="normal")
1448+
self.lookup_status.configure(text=f"Asking Discogs about {len(recs)} tracks…")
1449+
self.after(2000, self._tick)
1450+
results = self.suggested # worker fills this in as it goes
1451+
13971452
def work(progress, log):
1398-
return genres.bulk_lookup(recs, progress=progress, log=log)
1453+
return genres.bulk_lookup(
1454+
recs, progress=progress, log=log,
1455+
on_result=lambda path, val: results.__setitem__(path, val),
1456+
should_stop=self._stop.is_set)
13991457

14001458
def done(found):
1401-
self.suggested.update(found)
1402-
got = sum(1 for v in found.values() if v[0])
1459+
self._looking = False
1460+
self.stop_btn.configure(state="disabled")
1461+
got = sum(1 for v in self.suggested.values() if v[0])
1462+
self.lookup_status.configure(
1463+
text=f"Discogs finished — {got} tracks have a suggested genre.")
14031464
self.refresh()
14041465
messagebox.showinfo(
1405-
APP, f"Discogs matched {len(found)} tracks; {got} map to a genre "
1406-
"Sortero recognises.\n\nReview the Suggested column, then "
1466+
APP, f"Looked up {len(found)} tracks; {got} map to a genre Sortero "
1467+
"recognises.\n\nReview the Suggested column, then "
14071468
"'Accept suggestions'.")
14081469

14091470
self.app.task.run(work, done, "Asking Discogs")

sortero/genres.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,19 @@
99
Discogs' search works without an API token, but the free tier is rate limited,
1010
so lookups are paced and cached on disk - re-running never re-asks.
1111
"""
12-
import json, os, re, time, urllib.parse, urllib.request
12+
import json, os, re, time, urllib.error, urllib.parse, urllib.request
1313

14-
from . import net, paths
14+
from . import net, paths, settings
1515
from .journal import Journal
1616
from .organize import canon_genre
1717
from .tagio import Track
1818
from .version import __version__
1919

2020
UA = f"Sortero/{__version__} +https://github.com/sparkly-quasar/sortero"
2121
SEARCH = "https://api.discogs.com/database/search"
22-
MIN_INTERVAL = 2.5 # unauthenticated Discogs allows ~25 requests/minute
22+
ANON_INTERVAL = 2.5 # unauthenticated Discogs allows ~25 requests/minute
23+
TOKEN_INTERVAL = 1.0 # a free personal token raises it to ~60/minute
24+
MIN_INTERVAL = ANON_INTERVAL
2325
CACHE = "discogs-cache.json"
2426

2527
_last_call = [0.0]
@@ -72,18 +74,38 @@ def key_for(rec):
7274
return f"{clean_artist(rec.artist)}|{clean_title(rec.title)}".lower()
7375

7476

77+
def worth_asking(rec):
78+
"""A query needs both halves; without them Discogs can only guess."""
79+
return bool(clean_artist(rec.artist) and clean_title(rec.title))
80+
81+
7582
# ------------------------------------------------------------------ lookup
83+
def token():
84+
return (settings.get("discogs_token") or "").strip()
85+
86+
87+
def interval():
88+
return TOKEN_INTERVAL if token() else ANON_INTERVAL
89+
90+
91+
def eta_minutes(n):
92+
return max(1, round(n * interval() / 60))
93+
94+
7695
def _throttle():
77-
wait = MIN_INTERVAL - (time.time() - _last_call[0])
96+
wait = interval() - (time.time() - _last_call[0])
7897
if wait > 0:
7998
time.sleep(wait)
8099
_last_call[0] = time.time()
81100

82101

83102
def _search(artist, title, timeout=25):
84-
q = urllib.parse.urlencode({"artist": artist, "track": title,
85-
"type": "release", "per_page": "5"})
86-
req = urllib.request.Request(f"{SEARCH}?{q}", headers={"User-Agent": UA})
103+
params = {"artist": artist, "track": title, "type": "release", "per_page": "5"}
104+
tok = token()
105+
if tok:
106+
params["token"] = tok
107+
req = urllib.request.Request(f"{SEARCH}?{urllib.parse.urlencode(params)}",
108+
headers={"User-Agent": UA})
87109
for attempt in range(3):
88110
_throttle()
89111
try:
@@ -125,27 +147,50 @@ def lookup(rec, cache=None):
125147
return None, raw
126148

127149

128-
def bulk_lookup(recs, detail="fine", progress=None, log=print):
129-
"""Look up many tracks. Returns {path: (suggestion, raw_styles)}."""
150+
def bulk_lookup(recs, detail="fine", progress=None, log=print,
151+
on_result=None, should_stop=None, save_every=10):
152+
"""Look up many tracks.
153+
154+
Reports as it goes and saves the cache periodically, so a long run is both
155+
visible and resumable - stopping halfway keeps everything already fetched.
156+
Returns {path: (suggestion, raw_styles)}.
157+
"""
130158
cache = load_cache()
131159
out = {}
132160
total = len(recs) or 1
161+
matched = 0
162+
started = time.time()
163+
done = 0
133164
try:
134165
for i, r in enumerate(recs):
166+
if should_stop is not None and should_stop():
167+
log(f"stopped after {i} of {total}")
168+
break
135169
if progress:
136170
progress(i, total)
137171
try:
138172
g, raw = lookup(r, cache)
139173
except LookupError as e:
140174
log(f"stopped: {e}")
141175
break
176+
done += 1
142177
if g or raw:
143178
out[r.path] = (g, raw)
179+
if on_result:
180+
on_result(r.path, (g, raw))
181+
if g:
182+
matched += 1
183+
if done % save_every == 0:
184+
save_cache(cache)
185+
rate = (time.time() - started) / max(done, 1)
186+
left = int(rate * (total - i - 1) / 60)
187+
log(f"Discogs {i+1}/{total} · {matched} with a genre · "
188+
f"~{left} min left")
144189
finally:
145190
save_cache(cache)
146191
if progress:
147192
progress(total, total)
148-
log(f"looked up {len(out)} of {total} tracks")
193+
log(f"looked up {len(out)} of {total} tracks ({matched} mapped to a genre)")
149194
return out
150195

151196

sortero/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.7.0"
1+
__version__ = "0.7.1"

0 commit comments

Comments
 (0)