agent-discogs 0.1.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.
- agent_discogs/__init__.py +151 -0
- agent_discogs/__main__.py +5 -0
- agent_discogs/client.py +30 -0
- agent_discogs/commands/__init__.py +0 -0
- agent_discogs/commands/cache.py +16 -0
- agent_discogs/commands/get.py +794 -0
- agent_discogs/commands/search.py +275 -0
- agent_discogs/commands/skills.py +92 -0
- agent_discogs/commands/status.py +22 -0
- agent_discogs/errors.py +149 -0
- agent_discogs/formatting.py +741 -0
- agent_discogs/json_output.py +66 -0
- agent_discogs/pagination.py +190 -0
- agent_discogs/projections.py +300 -0
- agent_discogs/py.typed +0 -0
- agent_discogs/refs.py +59 -0
- agent_discogs/skills/core/SKILL.md +140 -0
- agent_discogs/skills/core/references/commands.md +174 -0
- agent_discogs/skills/core/references/discogs-domain.md +278 -0
- agent_discogs/skills/core/references/pressings-guide.md +77 -0
- agent_discogs/skills/core/references/search-patterns.md +72 -0
- agent_discogs-0.1.0.dist-info/METADATA +210 -0
- agent_discogs-0.1.0.dist-info/RECORD +26 -0
- agent_discogs-0.1.0.dist-info/WHEEL +4 -0
- agent_discogs-0.1.0.dist-info/entry_points.txt +3 -0
- agent_discogs-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
"""Get command handler — dispatches by noun to entity-specific functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from discogs_sdk import ArtistRelease, Discogs, LabelRelease, MasterVersion
|
|
10
|
+
|
|
11
|
+
from agent_discogs.client import get_client
|
|
12
|
+
from agent_discogs.errors import error_document, fail, format_error
|
|
13
|
+
from agent_discogs.formatting import (
|
|
14
|
+
format_artist,
|
|
15
|
+
format_artist_releases,
|
|
16
|
+
format_credits,
|
|
17
|
+
format_identifiers,
|
|
18
|
+
format_label,
|
|
19
|
+
format_label_releases,
|
|
20
|
+
format_master,
|
|
21
|
+
format_master_versions,
|
|
22
|
+
format_price_guide,
|
|
23
|
+
format_release,
|
|
24
|
+
format_tracklist,
|
|
25
|
+
)
|
|
26
|
+
from agent_discogs.json_output import Mode, dump, raw
|
|
27
|
+
from agent_discogs.pagination import (
|
|
28
|
+
DEFAULT_LIMIT,
|
|
29
|
+
fetch_filtered_page,
|
|
30
|
+
fetch_page,
|
|
31
|
+
next_page_cmd,
|
|
32
|
+
parse_cursor,
|
|
33
|
+
)
|
|
34
|
+
from agent_discogs.projections import (
|
|
35
|
+
drop_empty,
|
|
36
|
+
project_artist,
|
|
37
|
+
project_artist_release,
|
|
38
|
+
project_credits,
|
|
39
|
+
project_identifiers,
|
|
40
|
+
project_label,
|
|
41
|
+
project_label_release,
|
|
42
|
+
project_master,
|
|
43
|
+
project_master_version,
|
|
44
|
+
project_price,
|
|
45
|
+
project_release,
|
|
46
|
+
project_tracklist,
|
|
47
|
+
)
|
|
48
|
+
from agent_discogs.refs import make_ref, parse_ref
|
|
49
|
+
|
|
50
|
+
# Which entity type each noun expects
|
|
51
|
+
NOUN_EXPECTED_TYPE = {
|
|
52
|
+
"artist": "artist",
|
|
53
|
+
"credits": "release",
|
|
54
|
+
"identifiers": "release",
|
|
55
|
+
"label": "label",
|
|
56
|
+
"master": "master",
|
|
57
|
+
"price": "release",
|
|
58
|
+
"release": "release",
|
|
59
|
+
"releases": "artist",
|
|
60
|
+
"tracklist": "release",
|
|
61
|
+
"versions": "master",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
NOUN_ALIASES = {"ids": "identifiers"}
|
|
65
|
+
|
|
66
|
+
GET_NOUNS = [*NOUN_EXPECTED_TYPE, *NOUN_ALIASES]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_ref(ref_string: str, noun: str) -> tuple[str, int]:
|
|
70
|
+
"""Parse a ref or raw ID, validating type against noun expectation.
|
|
71
|
+
|
|
72
|
+
Returns (entity_type, entity_id).
|
|
73
|
+
Raises ValueError with helpful message on mismatch.
|
|
74
|
+
|
|
75
|
+
When a raw numeric ID is given, parse_ref returns type "unknown".
|
|
76
|
+
We skip type validation in that case and trust the noun to determine
|
|
77
|
+
the entity type (e.g. "get release 367113" treats 367113 as a release).
|
|
78
|
+
This accommodates AI agents that pass raw Discogs IDs without our
|
|
79
|
+
typed-prefix convention.
|
|
80
|
+
"""
|
|
81
|
+
entity_type, entity_id = parse_ref(ref_string)
|
|
82
|
+
|
|
83
|
+
expected = NOUN_EXPECTED_TYPE.get(noun)
|
|
84
|
+
if entity_type == "unknown" or expected is None:
|
|
85
|
+
return entity_type, entity_id
|
|
86
|
+
|
|
87
|
+
if entity_type != expected:
|
|
88
|
+
# Smart resolution: versions with a release ref
|
|
89
|
+
if noun == "versions" and entity_type == "release":
|
|
90
|
+
return entity_type, entity_id
|
|
91
|
+
# A label has a catalogue too: `get releases @l647`.
|
|
92
|
+
if noun == "releases" and entity_type == "label":
|
|
93
|
+
return entity_type, entity_id
|
|
94
|
+
|
|
95
|
+
def _a(word: str) -> str:
|
|
96
|
+
return f"an {word}" if word[0] in "aeiou" else f"a {word}"
|
|
97
|
+
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"{ref_string} is {_a(entity_type)}, not {_a(expected)}. "
|
|
100
|
+
f"Use {_a(expected)} ref or ID with 'get {noun}'."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return entity_type, entity_id
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_artist(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
107
|
+
artist = client.artists.get(entity_id)
|
|
108
|
+
if mode.json:
|
|
109
|
+
return mode.entity(artist, project_artist)
|
|
110
|
+
return format_artist(artist)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_credits(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
114
|
+
release = client.releases.get(entity_id)
|
|
115
|
+
if mode.full:
|
|
116
|
+
return {"credits": [c.model_dump() for c in release.extra_artists or []]}
|
|
117
|
+
if mode.json:
|
|
118
|
+
return drop_empty(
|
|
119
|
+
{"ref": make_ref("release", entity_id), "credits": project_credits(release)}
|
|
120
|
+
)
|
|
121
|
+
return format_credits(release)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_identifiers(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
125
|
+
release = client.releases.get(entity_id)
|
|
126
|
+
if mode.full:
|
|
127
|
+
return {"identifiers": [i.model_dump() for i in release.identifiers or []]}
|
|
128
|
+
if mode.json:
|
|
129
|
+
return drop_empty(
|
|
130
|
+
{
|
|
131
|
+
"ref": make_ref("release", entity_id),
|
|
132
|
+
"identifiers": project_identifiers(release),
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
return format_identifiers(release)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get_label(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
139
|
+
label = client.labels.get(entity_id)
|
|
140
|
+
if mode.json:
|
|
141
|
+
return mode.entity(label, project_label)
|
|
142
|
+
return format_label(label)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_master(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
146
|
+
master = client.masters.get(entity_id)
|
|
147
|
+
if mode.json:
|
|
148
|
+
return mode.entity(master, project_master)
|
|
149
|
+
return format_master(master)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_price(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
153
|
+
release = client.releases.get(entity_id)
|
|
154
|
+
price_suggestions = release.price_suggestions.get()
|
|
155
|
+
marketplace_stats = release.marketplace_stats.get()
|
|
156
|
+
if mode.full:
|
|
157
|
+
return {**raw(price_suggestions), "marketplace_stats": raw(marketplace_stats)}
|
|
158
|
+
if mode.json:
|
|
159
|
+
return project_price(release, price_suggestions, marketplace_stats)
|
|
160
|
+
return format_price_guide(release, price_suggestions, marketplace_stats)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _get_release(
|
|
164
|
+
client: Discogs, entity_id: int, *, verbose: bool, compact: bool, mode: Mode
|
|
165
|
+
) -> Any:
|
|
166
|
+
release = client.releases.get(entity_id)
|
|
167
|
+
if mode.json:
|
|
168
|
+
return mode.entity(
|
|
169
|
+
release, lambda r: project_release(r, verbose=verbose, compact=compact)
|
|
170
|
+
)
|
|
171
|
+
return format_release(release, verbose=verbose, compact=compact)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _sort_params(sort: str | None, desc: bool) -> dict[str, str]:
|
|
175
|
+
"""Server-side ordering; `--desc` only means something with `--sort`."""
|
|
176
|
+
if not sort:
|
|
177
|
+
return {}
|
|
178
|
+
return {"sort": sort, "sort_order": "desc" if desc else "asc"}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_label_releases(
|
|
182
|
+
client: Discogs,
|
|
183
|
+
entity_id: int,
|
|
184
|
+
*,
|
|
185
|
+
page: int | None,
|
|
186
|
+
limit: int,
|
|
187
|
+
after: str | None,
|
|
188
|
+
year: str | None,
|
|
189
|
+
mode: Mode,
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""A label's catalogue. The API has no filters or sorting here, so `--year`
|
|
192
|
+
is a client-side scan with cursor continuation (like `--role`)."""
|
|
193
|
+
label = client.labels.get(entity_id)
|
|
194
|
+
label_ref = make_ref("label", entity_id)
|
|
195
|
+
path = f"/labels/{entity_id}/releases"
|
|
196
|
+
|
|
197
|
+
if year:
|
|
198
|
+
result = fetch_filtered_page(
|
|
199
|
+
client,
|
|
200
|
+
path,
|
|
201
|
+
{},
|
|
202
|
+
LabelRelease,
|
|
203
|
+
"releases",
|
|
204
|
+
limit=limit,
|
|
205
|
+
keep=lambda item: str(getattr(item, "year", None) or "") == year,
|
|
206
|
+
cursor=after,
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
result = fetch_page(
|
|
210
|
+
client,
|
|
211
|
+
path,
|
|
212
|
+
{"page": page or 1, "per_page": limit},
|
|
213
|
+
LabelRelease,
|
|
214
|
+
"releases",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if mode.json:
|
|
218
|
+
return mode.page(result, project_label_release)
|
|
219
|
+
|
|
220
|
+
footer_cmd = None
|
|
221
|
+
if result.has_next:
|
|
222
|
+
footer_cmd = next_page_cmd(
|
|
223
|
+
["get", "releases", label_ref],
|
|
224
|
+
year=year,
|
|
225
|
+
limit=limit if limit != DEFAULT_LIMIT else None,
|
|
226
|
+
after=result.next_cursor,
|
|
227
|
+
page=None if result.filtered else result.page + 1,
|
|
228
|
+
)
|
|
229
|
+
return format_label_releases(
|
|
230
|
+
result.items,
|
|
231
|
+
label_ref,
|
|
232
|
+
label.name,
|
|
233
|
+
result.page,
|
|
234
|
+
result.total_items,
|
|
235
|
+
footer_cmd,
|
|
236
|
+
filtered=result.filtered,
|
|
237
|
+
capped=result.capped,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _get_releases(
|
|
242
|
+
client: Discogs,
|
|
243
|
+
entity_type: str,
|
|
244
|
+
entity_id: int,
|
|
245
|
+
*,
|
|
246
|
+
page: int | None,
|
|
247
|
+
limit: int,
|
|
248
|
+
after: str | None,
|
|
249
|
+
role: str | None,
|
|
250
|
+
year: str | None,
|
|
251
|
+
sort: str | None,
|
|
252
|
+
desc: bool,
|
|
253
|
+
mode: Mode,
|
|
254
|
+
) -> Any:
|
|
255
|
+
if entity_type == "label":
|
|
256
|
+
return _get_label_releases(
|
|
257
|
+
client,
|
|
258
|
+
entity_id,
|
|
259
|
+
page=page,
|
|
260
|
+
limit=limit,
|
|
261
|
+
after=after,
|
|
262
|
+
year=year,
|
|
263
|
+
mode=mode,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
artist = client.artists.get(entity_id)
|
|
267
|
+
artist_name = artist.name
|
|
268
|
+
artist_ref = make_ref("artist", entity_id)
|
|
269
|
+
|
|
270
|
+
path = f"/artists/{entity_id}/releases"
|
|
271
|
+
params: dict[str, Any] = _sort_params(sort, desc)
|
|
272
|
+
|
|
273
|
+
# Flag validity is checked in _dispatch before any API call.
|
|
274
|
+
if role:
|
|
275
|
+
role_lower = role.lower()
|
|
276
|
+
result = fetch_filtered_page(
|
|
277
|
+
client,
|
|
278
|
+
path,
|
|
279
|
+
params,
|
|
280
|
+
ArtistRelease,
|
|
281
|
+
"releases",
|
|
282
|
+
limit=limit,
|
|
283
|
+
keep=lambda item: role_lower in (getattr(item, "role", "") or "").lower(),
|
|
284
|
+
cursor=after,
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
params["page"] = page or 1
|
|
288
|
+
params["per_page"] = limit
|
|
289
|
+
result = fetch_page(client, path, params, ArtistRelease, "releases")
|
|
290
|
+
|
|
291
|
+
if mode.json:
|
|
292
|
+
return mode.page(result, project_artist_release)
|
|
293
|
+
|
|
294
|
+
footer_cmd = None
|
|
295
|
+
if result.has_next:
|
|
296
|
+
footer_cmd = next_page_cmd(
|
|
297
|
+
["get", "releases", artist_ref],
|
|
298
|
+
role=role,
|
|
299
|
+
sort=sort,
|
|
300
|
+
desc=desc,
|
|
301
|
+
limit=limit if limit != DEFAULT_LIMIT else None,
|
|
302
|
+
after=result.next_cursor,
|
|
303
|
+
page=None if result.filtered else result.page + 1,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return format_artist_releases(
|
|
307
|
+
result.items,
|
|
308
|
+
artist_ref,
|
|
309
|
+
artist_name,
|
|
310
|
+
result.page,
|
|
311
|
+
result.total_items,
|
|
312
|
+
footer_cmd,
|
|
313
|
+
filtered=result.filtered,
|
|
314
|
+
capped=result.capped,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _get_tracklist(client: Discogs, entity_id: int, mode: Mode) -> Any:
|
|
319
|
+
release = client.releases.get(entity_id)
|
|
320
|
+
if mode.full:
|
|
321
|
+
return {"tracklist": [t.model_dump() for t in release.tracklist or []]}
|
|
322
|
+
if mode.json:
|
|
323
|
+
return project_tracklist(release)
|
|
324
|
+
return format_tracklist(release)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _get_versions(
|
|
328
|
+
client: Discogs,
|
|
329
|
+
entity_type: str,
|
|
330
|
+
entity_id: int,
|
|
331
|
+
ref_string: str,
|
|
332
|
+
*,
|
|
333
|
+
page: int | None,
|
|
334
|
+
limit: int,
|
|
335
|
+
country: str | None,
|
|
336
|
+
format: str | None, # noqa: A002 # click option name for --format
|
|
337
|
+
label: str | None,
|
|
338
|
+
year: str | None,
|
|
339
|
+
sort: str | None,
|
|
340
|
+
desc: bool,
|
|
341
|
+
mode: Mode,
|
|
342
|
+
) -> Any:
|
|
343
|
+
master_id = entity_id
|
|
344
|
+
master_title = ""
|
|
345
|
+
|
|
346
|
+
# Smart resolution: release ref → look up its master_id
|
|
347
|
+
if entity_type == "release":
|
|
348
|
+
release = client.releases.get(entity_id)
|
|
349
|
+
if not release.master_id:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"{ref_string} is a release with no master. "
|
|
352
|
+
"This release isn't linked to a master release. "
|
|
353
|
+
"Try searching for the master directly: "
|
|
354
|
+
f'agent-discogs search master "{release.title}"'
|
|
355
|
+
)
|
|
356
|
+
master_id = release.master_id
|
|
357
|
+
master_title = release.title
|
|
358
|
+
else:
|
|
359
|
+
master = client.masters.get(master_id)
|
|
360
|
+
master_title = master.title
|
|
361
|
+
|
|
362
|
+
master_ref = make_ref("master", master_id)
|
|
363
|
+
|
|
364
|
+
params: dict[str, Any] = {"page": page or 1, "per_page": limit}
|
|
365
|
+
if country:
|
|
366
|
+
params["country"] = country
|
|
367
|
+
if format:
|
|
368
|
+
params["format"] = format
|
|
369
|
+
if label:
|
|
370
|
+
params["label"] = label
|
|
371
|
+
if year:
|
|
372
|
+
params["released"] = year # the API calls the year filter `released`
|
|
373
|
+
params.update(_sort_params(sort, desc))
|
|
374
|
+
|
|
375
|
+
result = fetch_page(
|
|
376
|
+
client,
|
|
377
|
+
f"/masters/{master_id}/versions",
|
|
378
|
+
params,
|
|
379
|
+
MasterVersion,
|
|
380
|
+
"versions",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if mode.json:
|
|
384
|
+
return mode.page(result, project_master_version)
|
|
385
|
+
|
|
386
|
+
footer_cmd = None
|
|
387
|
+
if result.has_next:
|
|
388
|
+
footer_cmd = next_page_cmd(
|
|
389
|
+
["get", "versions", master_ref],
|
|
390
|
+
country=country,
|
|
391
|
+
format=format,
|
|
392
|
+
label=label,
|
|
393
|
+
year=year,
|
|
394
|
+
sort=sort,
|
|
395
|
+
desc=desc,
|
|
396
|
+
limit=limit if limit != DEFAULT_LIMIT else None,
|
|
397
|
+
page=result.page + 1,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return format_master_versions(
|
|
401
|
+
result.items,
|
|
402
|
+
master_ref,
|
|
403
|
+
master_title,
|
|
404
|
+
result.page,
|
|
405
|
+
result.total_items,
|
|
406
|
+
footer_cmd,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
SORT_KEYS: dict[str, tuple[str, ...]] = {
|
|
411
|
+
"releases": ("year", "title", "format"),
|
|
412
|
+
"versions": ("released", "title", "format", "label", "catno", "country"),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _flag_error(
|
|
417
|
+
noun: str,
|
|
418
|
+
refs: tuple[str, ...],
|
|
419
|
+
*,
|
|
420
|
+
page: int | None,
|
|
421
|
+
after: str | None,
|
|
422
|
+
role: str | None,
|
|
423
|
+
year: str | None,
|
|
424
|
+
sort: str | None,
|
|
425
|
+
desc: bool,
|
|
426
|
+
) -> str | None:
|
|
427
|
+
"""Reject flag combinations that make no sense for this noun.
|
|
428
|
+
|
|
429
|
+
Checked before any API call so a bad flag never costs a request or gets
|
|
430
|
+
masked by an auth/network failure. Client-side filtered listings (which
|
|
431
|
+
page by cursor, not by number) are `releases --role` on an artist and
|
|
432
|
+
`releases --year` on a label.
|
|
433
|
+
"""
|
|
434
|
+
label_refs = noun == "releases" and any(r.startswith("@l") for r in refs)
|
|
435
|
+
artist_refs = noun == "releases" and not all(r.startswith("@l") for r in refs)
|
|
436
|
+
filtered = noun == "releases" and (bool(role) or (label_refs and bool(year)))
|
|
437
|
+
if after is not None and not filtered:
|
|
438
|
+
return (
|
|
439
|
+
"--after only continues a client-side filtered listing "
|
|
440
|
+
"(get releases --role, or get releases @l... --year). This command "
|
|
441
|
+
"pages server-side or is not paginated; use --page where applicable."
|
|
442
|
+
)
|
|
443
|
+
if filtered and page is not None:
|
|
444
|
+
return (
|
|
445
|
+
"--page is not available while results are filtered client-side "
|
|
446
|
+
"(--role, or --year on a label catalogue). Use the Next page "
|
|
447
|
+
"command from the previous output."
|
|
448
|
+
)
|
|
449
|
+
if after is not None:
|
|
450
|
+
try:
|
|
451
|
+
parse_cursor(after)
|
|
452
|
+
except ValueError as exc:
|
|
453
|
+
return str(exc)
|
|
454
|
+
if year and noun not in ("versions", "releases"):
|
|
455
|
+
return "--year applies to 'get versions' and 'get releases @l...' only."
|
|
456
|
+
if year and artist_refs:
|
|
457
|
+
return (
|
|
458
|
+
"--year filters versions and label catalogues; for an artist "
|
|
459
|
+
"discography use --sort year (optionally --desc) and page."
|
|
460
|
+
)
|
|
461
|
+
if (sort or desc) and noun not in SORT_KEYS:
|
|
462
|
+
return "--sort/--desc apply to 'get releases' and 'get versions' only."
|
|
463
|
+
if desc and not sort:
|
|
464
|
+
return "--desc needs --sort <key>."
|
|
465
|
+
if sort and sort not in SORT_KEYS.get(noun, ()):
|
|
466
|
+
keys = ", ".join(SORT_KEYS[noun])
|
|
467
|
+
return f"--sort {sort!r} is not valid for 'get {noun}'. Keys: {keys}."
|
|
468
|
+
if label_refs and (role or sort):
|
|
469
|
+
return (
|
|
470
|
+
"Label catalogues cannot be role-filtered or sorted: the Discogs API "
|
|
471
|
+
"offers no --role or --sort for /labels/{id}/releases. Use --year "
|
|
472
|
+
"(client-side), page with --page, or search: "
|
|
473
|
+
"agent-discogs search release --label <name>."
|
|
474
|
+
)
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _mode(json_output: bool, full: bool) -> Mode:
|
|
479
|
+
"""`--full` is a JSON shape switch, so it needs `--json`; say so up front."""
|
|
480
|
+
if full and not json_output:
|
|
481
|
+
fail(ValueError("--full requires --json."), json_output=False)
|
|
482
|
+
return Mode(json=json_output, full=full)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
MAX_REFS = 10
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _run_one(
|
|
489
|
+
client: Discogs,
|
|
490
|
+
noun: str,
|
|
491
|
+
ref: str,
|
|
492
|
+
*,
|
|
493
|
+
page: int | None,
|
|
494
|
+
after: str | None,
|
|
495
|
+
limit: int,
|
|
496
|
+
country: str | None,
|
|
497
|
+
format: str | None, # noqa: A002 # click option name for --format
|
|
498
|
+
label: str | None,
|
|
499
|
+
year: str | None,
|
|
500
|
+
sort: str | None,
|
|
501
|
+
desc: bool,
|
|
502
|
+
role: str | None,
|
|
503
|
+
verbose: bool,
|
|
504
|
+
compact: bool,
|
|
505
|
+
mode: Mode,
|
|
506
|
+
) -> Any:
|
|
507
|
+
"""One noun for one ref: text, or the JSON document."""
|
|
508
|
+
entity_type, entity_id = _resolve_ref(ref, noun)
|
|
509
|
+
|
|
510
|
+
if noun == "artist":
|
|
511
|
+
return _get_artist(client, entity_id, mode)
|
|
512
|
+
if noun == "credits":
|
|
513
|
+
return _get_credits(client, entity_id, mode)
|
|
514
|
+
if noun == "identifiers":
|
|
515
|
+
return _get_identifiers(client, entity_id, mode)
|
|
516
|
+
if noun == "master":
|
|
517
|
+
return _get_master(client, entity_id, mode)
|
|
518
|
+
if noun == "label":
|
|
519
|
+
return _get_label(client, entity_id, mode)
|
|
520
|
+
if noun == "price":
|
|
521
|
+
return _get_price(client, entity_id, mode)
|
|
522
|
+
if noun == "release":
|
|
523
|
+
return _get_release(
|
|
524
|
+
client, entity_id, verbose=verbose, compact=compact, mode=mode
|
|
525
|
+
)
|
|
526
|
+
if noun == "releases":
|
|
527
|
+
return _get_releases(
|
|
528
|
+
client,
|
|
529
|
+
entity_type,
|
|
530
|
+
entity_id,
|
|
531
|
+
page=page,
|
|
532
|
+
after=after,
|
|
533
|
+
limit=limit,
|
|
534
|
+
role=role,
|
|
535
|
+
year=year,
|
|
536
|
+
sort=sort,
|
|
537
|
+
desc=desc,
|
|
538
|
+
mode=mode,
|
|
539
|
+
)
|
|
540
|
+
if noun == "tracklist":
|
|
541
|
+
return _get_tracklist(client, entity_id, mode)
|
|
542
|
+
return _get_versions(
|
|
543
|
+
client,
|
|
544
|
+
entity_type,
|
|
545
|
+
entity_id,
|
|
546
|
+
ref,
|
|
547
|
+
page=page,
|
|
548
|
+
limit=limit,
|
|
549
|
+
country=country,
|
|
550
|
+
format=format,
|
|
551
|
+
label=label,
|
|
552
|
+
year=year,
|
|
553
|
+
sort=sort,
|
|
554
|
+
desc=desc,
|
|
555
|
+
mode=mode,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _dispatch(
|
|
560
|
+
noun: str,
|
|
561
|
+
refs: tuple[str, ...],
|
|
562
|
+
*,
|
|
563
|
+
page: int | None,
|
|
564
|
+
after: str | None,
|
|
565
|
+
limit: int,
|
|
566
|
+
country: str | None,
|
|
567
|
+
format: str | None, # noqa: A002 # click option name for --format
|
|
568
|
+
label: str | None,
|
|
569
|
+
year: str | None,
|
|
570
|
+
sort: str | None,
|
|
571
|
+
desc: bool,
|
|
572
|
+
role: str | None,
|
|
573
|
+
verbose: bool,
|
|
574
|
+
compact: bool,
|
|
575
|
+
mode: Mode,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Shared dispatch for get, tracks, and price: one or many refs.
|
|
578
|
+
|
|
579
|
+
Several refs run in sequence, one API round trip each, so "compare these
|
|
580
|
+
pressings" is one command. Output is one block per ref in ref order,
|
|
581
|
+
blank-line separated, on stdout: a failing ref contributes its `✗` error
|
|
582
|
+
block and the rest still run; JSON becomes a list with `{"ref", "error"}`
|
|
583
|
+
items for failures. Exit 1 if any ref failed. With one ref the shape is
|
|
584
|
+
unchanged, including the single-error path (`fail()`: stderr text or a
|
|
585
|
+
JSON envelope).
|
|
586
|
+
"""
|
|
587
|
+
flag_error = _flag_error(
|
|
588
|
+
noun,
|
|
589
|
+
refs,
|
|
590
|
+
page=page,
|
|
591
|
+
after=after,
|
|
592
|
+
role=role,
|
|
593
|
+
year=year,
|
|
594
|
+
sort=sort,
|
|
595
|
+
desc=desc,
|
|
596
|
+
)
|
|
597
|
+
if flag_error:
|
|
598
|
+
fail(ValueError(flag_error), json_output=mode.json)
|
|
599
|
+
if len(refs) > MAX_REFS:
|
|
600
|
+
fail(
|
|
601
|
+
ValueError(f"At most {MAX_REFS} refs per command ({len(refs)} given)."),
|
|
602
|
+
json_output=mode.json,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
client = get_client()
|
|
607
|
+
# classify() maps every exception to a coded, recovery-oriented error, so
|
|
608
|
+
# catching broadly is the point.
|
|
609
|
+
except Exception as e: # noqa: BLE001
|
|
610
|
+
fail(e, json_output=mode.json)
|
|
611
|
+
|
|
612
|
+
single = len(refs) == 1
|
|
613
|
+
blocks: list[Any] = [] # one per ref, in order: text/document or error
|
|
614
|
+
failed = False
|
|
615
|
+
for ref in refs:
|
|
616
|
+
context = f"{noun.title()} {ref}"
|
|
617
|
+
try:
|
|
618
|
+
blocks.append(
|
|
619
|
+
_run_one(
|
|
620
|
+
client,
|
|
621
|
+
noun,
|
|
622
|
+
ref,
|
|
623
|
+
page=page,
|
|
624
|
+
after=after,
|
|
625
|
+
limit=limit,
|
|
626
|
+
country=country,
|
|
627
|
+
format=format,
|
|
628
|
+
label=label,
|
|
629
|
+
year=year,
|
|
630
|
+
sort=sort,
|
|
631
|
+
desc=desc,
|
|
632
|
+
role=role,
|
|
633
|
+
verbose=verbose,
|
|
634
|
+
compact=compact,
|
|
635
|
+
mode=mode,
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
except Exception as e: # noqa: BLE001
|
|
639
|
+
if single:
|
|
640
|
+
fail(e, context, json_output=mode.json)
|
|
641
|
+
failed = True
|
|
642
|
+
if mode.json:
|
|
643
|
+
blocks.append({"ref": ref, "error": error_document(e, context)})
|
|
644
|
+
else:
|
|
645
|
+
blocks.append(format_error(e, context))
|
|
646
|
+
|
|
647
|
+
if mode.json:
|
|
648
|
+
dump(blocks[0] if single else blocks)
|
|
649
|
+
else:
|
|
650
|
+
print("\n\n".join(blocks))
|
|
651
|
+
if failed:
|
|
652
|
+
sys.exit(1)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
_JSON_OPTIONS = [
|
|
656
|
+
click.option(
|
|
657
|
+
"--json",
|
|
658
|
+
"json_output",
|
|
659
|
+
is_flag=True,
|
|
660
|
+
default=False,
|
|
661
|
+
help="Output JSON (a compact projection of what the text view shows)",
|
|
662
|
+
),
|
|
663
|
+
click.option(
|
|
664
|
+
"--full",
|
|
665
|
+
is_flag=True,
|
|
666
|
+
default=False,
|
|
667
|
+
help="With --json: the raw SDK model instead of the projection",
|
|
668
|
+
),
|
|
669
|
+
]
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _json_options(command: Any) -> Any:
|
|
673
|
+
for option in reversed(_JSON_OPTIONS):
|
|
674
|
+
command = option(command)
|
|
675
|
+
return command
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@click.command()
|
|
679
|
+
@click.argument("noun", type=click.Choice(GET_NOUNS))
|
|
680
|
+
@click.argument("refs", nargs=-1, required=True, metavar="REF...")
|
|
681
|
+
@click.option(
|
|
682
|
+
"--after",
|
|
683
|
+
help="Continuation cursor copied from a previous Next page / Continue scan line",
|
|
684
|
+
)
|
|
685
|
+
@click.option(
|
|
686
|
+
"-c",
|
|
687
|
+
"--compact",
|
|
688
|
+
is_flag=True,
|
|
689
|
+
default=False,
|
|
690
|
+
help="release: replace the tracklist with a one-line Tracks: summary",
|
|
691
|
+
)
|
|
692
|
+
@click.option("--country", help="Filter versions by country")
|
|
693
|
+
@click.option(
|
|
694
|
+
"--desc", is_flag=True, default=False, help="With --sort: descending order"
|
|
695
|
+
)
|
|
696
|
+
@click.option("--format", "format_", help="Filter versions by format")
|
|
697
|
+
@_json_options
|
|
698
|
+
@click.option("--label", help="Filter versions by label")
|
|
699
|
+
@click.option("--limit", type=int, default=DEFAULT_LIMIT, help="Results per page")
|
|
700
|
+
@click.option("--page", type=int, help="Page number (server-side pages only)")
|
|
701
|
+
@click.option("--role", help="Filter releases by credit role (e.g., Main, Remix)")
|
|
702
|
+
@click.option(
|
|
703
|
+
"--sort",
|
|
704
|
+
help=(
|
|
705
|
+
"Server-side order. releases: year|title|format; "
|
|
706
|
+
"versions: released|title|format|label|catno|country"
|
|
707
|
+
),
|
|
708
|
+
)
|
|
709
|
+
@click.option(
|
|
710
|
+
"-v",
|
|
711
|
+
"--verbose",
|
|
712
|
+
is_flag=True,
|
|
713
|
+
default=False,
|
|
714
|
+
help="release: also print notes, credits, and identifiers",
|
|
715
|
+
)
|
|
716
|
+
@click.option("--year", help="Filter versions by release year")
|
|
717
|
+
def get(
|
|
718
|
+
noun: str,
|
|
719
|
+
refs: tuple[str, ...],
|
|
720
|
+
json_output: bool,
|
|
721
|
+
full: bool,
|
|
722
|
+
after: str | None,
|
|
723
|
+
compact: bool,
|
|
724
|
+
country: str | None,
|
|
725
|
+
desc: bool,
|
|
726
|
+
format_: str | None,
|
|
727
|
+
label: str | None,
|
|
728
|
+
limit: int,
|
|
729
|
+
page: int | None,
|
|
730
|
+
role: str | None,
|
|
731
|
+
sort: str | None,
|
|
732
|
+
verbose: bool,
|
|
733
|
+
year: str | None,
|
|
734
|
+
) -> None:
|
|
735
|
+
"""Get entity details for one or more refs.
|
|
736
|
+
|
|
737
|
+
NOUN is the entity type: artist, credits, identifiers (alias: ids), label,
|
|
738
|
+
master, price, release, releases, tracklist, versions.
|
|
739
|
+
REF is a typed ref (@r123, @a456) or raw Discogs ID. Several refs run in
|
|
740
|
+
sequence (at most 10).
|
|
741
|
+
"""
|
|
742
|
+
_dispatch(
|
|
743
|
+
NOUN_ALIASES.get(noun, noun),
|
|
744
|
+
refs,
|
|
745
|
+
page=page,
|
|
746
|
+
after=after,
|
|
747
|
+
limit=limit,
|
|
748
|
+
country=country,
|
|
749
|
+
format=format_,
|
|
750
|
+
label=label,
|
|
751
|
+
year=year,
|
|
752
|
+
sort=sort,
|
|
753
|
+
desc=desc,
|
|
754
|
+
role=role,
|
|
755
|
+
verbose=verbose,
|
|
756
|
+
compact=compact,
|
|
757
|
+
mode=_mode(json_output, full),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _shortcut(noun: str, refs: tuple[str, ...], json_output: bool, full: bool) -> None:
|
|
762
|
+
_dispatch(
|
|
763
|
+
noun,
|
|
764
|
+
refs,
|
|
765
|
+
page=None,
|
|
766
|
+
after=None,
|
|
767
|
+
limit=DEFAULT_LIMIT,
|
|
768
|
+
country=None,
|
|
769
|
+
format=None,
|
|
770
|
+
label=None,
|
|
771
|
+
year=None,
|
|
772
|
+
sort=None,
|
|
773
|
+
desc=False,
|
|
774
|
+
role=None,
|
|
775
|
+
verbose=False,
|
|
776
|
+
compact=False,
|
|
777
|
+
mode=_mode(json_output, full),
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@click.command()
|
|
782
|
+
@click.argument("refs", nargs=-1, required=True, metavar="REF...")
|
|
783
|
+
@_json_options
|
|
784
|
+
def tracks(refs: tuple[str, ...], json_output: bool, full: bool) -> None:
|
|
785
|
+
"""Shortcut for: get tracklist REF..."""
|
|
786
|
+
_shortcut("tracklist", refs, json_output, full)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@click.command()
|
|
790
|
+
@click.argument("refs", nargs=-1, required=True, metavar="REF...")
|
|
791
|
+
@_json_options
|
|
792
|
+
def price(refs: tuple[str, ...], json_output: bool, full: bool) -> None:
|
|
793
|
+
"""Shortcut for: get price REF..."""
|
|
794
|
+
_shortcut("price", refs, json_output, full)
|