stashapp-cli 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.
Files changed (41) hide show
  1. stashapp_cli/__init__.py +2 -0
  2. stashapp_cli/cli.py +149 -0
  3. stashapp_cli/commands/__init__.py +39 -0
  4. stashapp_cli/commands/_common.py +440 -0
  5. stashapp_cli/commands/backup.py +70 -0
  6. stashapp_cli/commands/generate.py +88 -0
  7. stashapp_cli/commands/graphql.py +212 -0
  8. stashapp_cli/commands/group.py +137 -0
  9. stashapp_cli/commands/introspect.py +35 -0
  10. stashapp_cli/commands/job.py +122 -0
  11. stashapp_cli/commands/performer.py +368 -0
  12. stashapp_cli/commands/scan.py +92 -0
  13. stashapp_cli/commands/scene.py +405 -0
  14. stashapp_cli/commands/scrape.py +133 -0
  15. stashapp_cli/commands/stashbox.py +173 -0
  16. stashapp_cli/commands/studio.py +227 -0
  17. stashapp_cli/commands/tag.py +694 -0
  18. stashapp_cli/core.py +165 -0
  19. stashapp_cli/images.py +72 -0
  20. stashapp_cli/introspect.py +343 -0
  21. stashapp_cli/py.typed +0 -0
  22. stashapp_cli/queries/__init__.py +40 -0
  23. stashapp_cli/queries/_get.py +27 -0
  24. stashapp_cli/queries/_image_flags.py +30 -0
  25. stashapp_cli/queries/backup.py +20 -0
  26. stashapp_cli/queries/group.py +117 -0
  27. stashapp_cli/queries/job.py +29 -0
  28. stashapp_cli/queries/metadata.py +42 -0
  29. stashapp_cli/queries/performer.py +179 -0
  30. stashapp_cli/queries/scene.py +139 -0
  31. stashapp_cli/queries/scrape.py +183 -0
  32. stashapp_cli/queries/sources.py +76 -0
  33. stashapp_cli/queries/stashbox.py +67 -0
  34. stashapp_cli/queries/studio.py +145 -0
  35. stashapp_cli/queries/tag.py +244 -0
  36. stashapp_cli/sources.py +116 -0
  37. stashapp_cli-0.1.0.dist-info/METADATA +208 -0
  38. stashapp_cli-0.1.0.dist-info/RECORD +41 -0
  39. stashapp_cli-0.1.0.dist-info/WHEEL +4 -0
  40. stashapp_cli-0.1.0.dist-info/entry_points.txt +3 -0
  41. stashapp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,405 @@
1
+ """scene commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from collections.abc import Callable
8
+
9
+ from .. import core, queries, sources
10
+ from ._common import (
11
+ LIST_OPTION_NOTE,
12
+ PERFORMER_IDS,
13
+ TAG_IDS,
14
+ URLS,
15
+ CommandKind,
16
+ FindSpec,
17
+ add_find_options,
18
+ add_get_command,
19
+ add_leaf,
20
+ add_list_options,
21
+ add_update_target,
22
+ bulk_update_input,
23
+ find_filter,
24
+ job_result,
25
+ mutation_result,
26
+ read_input_object,
27
+ require_list_target,
28
+ resolve_update_input,
29
+ )
30
+
31
+ LIST_FIELDS = (TAG_IDS, URLS, PERFORMER_IDS)
32
+ # Options that read the scene and send its full groups or stash_ids list.
33
+ SCENE_LIST_DESTS = ("add_group", "remove_group", "add_stash_id", "remove_stash_id")
34
+
35
+
36
+ FIND = FindSpec("scenes", "title", match_aliases=False, sort="path")
37
+
38
+
39
+ def cmd_scene_find(args: argparse.Namespace) -> object:
40
+ if args.code == "":
41
+ raise _usage_error("--code needs a non-empty value.")
42
+ extra = None
43
+ if args.code is not None:
44
+ extra = {"code": {"value": args.code, "modifier": "EQUALS"}}
45
+ return queries.scene.find_scenes(
46
+ find_filter(args, FIND, extra), args.page, args.per_page
47
+ )
48
+
49
+
50
+ def _usage_error(message: str) -> core.CliError:
51
+ return core.CliError(message, 2, "usage_error")
52
+
53
+
54
+ def parse_group_value(value: str) -> tuple[str, int | None]:
55
+ """Split `GROUP_ID[:SCENE_INDEX]` on its first `:`."""
56
+ group_id, sep, index = value.partition(":")
57
+ if not group_id:
58
+ raise _usage_error(f"--add-group {value!r} has no group ID.")
59
+ if not sep:
60
+ return group_id, None
61
+ if not re.fullmatch(r"[0-9]+", index):
62
+ raise _usage_error(
63
+ f"--add-group {value!r}: the scene index must be a non-negative integer."
64
+ )
65
+ return group_id, int(index)
66
+
67
+
68
+ def parse_stash_id_value(value: str) -> tuple[str, str]:
69
+ """Split `ENDPOINT:STASH_ID` on its last `:`, since endpoints contain `:`."""
70
+ endpoint, sep, stash_id = value.rpartition(":")
71
+ if not sep or not endpoint or not stash_id:
72
+ raise _usage_error(
73
+ f"--add-stash-id {value!r} must be ENDPOINT:STASH_ID with neither part empty."
74
+ )
75
+ return endpoint, stash_id
76
+
77
+
78
+ def _require_value(option: str, value: str) -> str:
79
+ if not value:
80
+ raise _usage_error(f"{option} needs a non-empty value.")
81
+ return value
82
+
83
+
84
+ def apply_group_changes(
85
+ current: list[dict],
86
+ add: list[tuple[str, int | None]],
87
+ remove: list[str],
88
+ ) -> list[dict]:
89
+ """The `SceneGroupInput` list after removing and then adding groups.
90
+
91
+ `current` is the scene's `groups` as read. A `scene_index` is sent only
92
+ when it is not null.
93
+ """
94
+ groups: list[dict] = []
95
+ for entry in current:
96
+ group_id = str(entry["group"]["id"])
97
+ if group_id in remove:
98
+ continue
99
+ group: dict = {"group_id": group_id}
100
+ if entry.get("scene_index") is not None:
101
+ group["scene_index"] = entry["scene_index"]
102
+ groups.append(group)
103
+ for group_id, scene_index in add:
104
+ existing: dict | None = next(
105
+ (g for g in groups if g["group_id"] == group_id), None
106
+ )
107
+ if existing is None:
108
+ existing = {"group_id": group_id}
109
+ groups.append(existing)
110
+ if scene_index is not None:
111
+ existing["scene_index"] = scene_index
112
+ return groups
113
+
114
+
115
+ def apply_stash_id_changes(
116
+ current: list[dict],
117
+ add: list[tuple[str, str]],
118
+ remove: list[str],
119
+ boxes: Callable[[], list[dict]],
120
+ ) -> list[dict]:
121
+ """The `StashIDInput` list after removing and then adding stash IDs.
122
+
123
+ `add` holds resolved endpoints. A `remove` value is an entry endpoint or
124
+ the name of a configured stash-box; `boxes` is called only when a value
125
+ matches no entry endpoint. A kept entry keeps its `updated_at`.
126
+ """
127
+ stash_ids = [
128
+ {
129
+ key: entry[key]
130
+ for key in ("endpoint", "stash_id", "updated_at")
131
+ if key in entry
132
+ }
133
+ for entry in current
134
+ ]
135
+ for value in remove:
136
+ endpoints = {value}
137
+ if not any(entry["endpoint"] == value for entry in stash_ids):
138
+ endpoints = {box["endpoint"] for box in boxes() if box["name"] == value}
139
+ stash_ids = [entry for entry in stash_ids if entry["endpoint"] not in endpoints]
140
+ for endpoint, stash_id in add:
141
+ existing = [
142
+ entry["stash_id"] for entry in stash_ids if entry["endpoint"] == endpoint
143
+ ]
144
+ if stash_id in existing:
145
+ continue
146
+ if existing:
147
+ raise core.CliError(
148
+ f"The scene already has stash ID {', '.join(existing)} for {endpoint}. "
149
+ f"To replace it, pass --remove-stash-id {endpoint} "
150
+ f"--add-stash-id {endpoint}:{stash_id}.",
151
+ 2,
152
+ "stash_id_conflict",
153
+ {"endpoint": endpoint, "stash_ids": existing},
154
+ )
155
+ stash_ids.append({"endpoint": endpoint, "stash_id": stash_id})
156
+ return stash_ids
157
+
158
+
159
+ def _update_scene_lists(args: argparse.Namespace) -> object:
160
+ """Read the scene, change its groups or stash_ids, and send one sceneUpdate."""
161
+ add_groups = [parse_group_value(value) for value in args.add_group or []]
162
+ remove_groups = [
163
+ _require_value("--remove-group", value) for value in args.remove_group or []
164
+ ]
165
+ add_stash_ids = [parse_stash_id_value(value) for value in args.add_stash_id or []]
166
+ remove_stash_ids = [
167
+ _require_value("--remove-stash-id", value)
168
+ for value in args.remove_stash_id or []
169
+ ]
170
+
171
+ stash_boxes: list[dict] | None = None
172
+
173
+ def boxes() -> list[dict]:
174
+ nonlocal stash_boxes
175
+ if stash_boxes is None:
176
+ stash_boxes = sources.list_stash_boxes()
177
+ return stash_boxes
178
+
179
+ if add_stash_ids:
180
+ add_stash_ids = [
181
+ (sources.resolve_stash_box(endpoint, boxes())["endpoint"], stash_id)
182
+ for endpoint, stash_id in add_stash_ids
183
+ ]
184
+
185
+ scene = queries.scene.get_scene_lists(args.id)
186
+ if scene is None:
187
+ raise core.not_found_error("scene", args.id)
188
+
189
+ input_data: dict = {"id": args.id}
190
+ if add_groups or remove_groups:
191
+ input_data["groups"] = apply_group_changes(
192
+ scene.get("groups") or [], add_groups, remove_groups
193
+ )
194
+ if add_stash_ids or remove_stash_ids:
195
+ input_data["stash_ids"] = apply_stash_id_changes(
196
+ scene.get("stash_ids") or [], add_stash_ids, remove_stash_ids, boxes
197
+ )
198
+ return mutation_result(
199
+ "sceneUpdate",
200
+ args.apply,
201
+ input_data,
202
+ lambda: queries.scene.update_scene(input_data),
203
+ )
204
+
205
+
206
+ def cmd_scene_update(args: argparse.Namespace) -> object:
207
+ scene_lists = any(getattr(args, dest) for dest in SCENE_LIST_DESTS)
208
+ bulk_input = bulk_update_input(args, LIST_FIELDS)
209
+ if scene_lists or bulk_input is not None:
210
+ require_list_target(args)
211
+ if scene_lists and bulk_input is not None:
212
+ raise _usage_error(
213
+ "The group and stash ID options cannot be combined with the tag, "
214
+ "URL, or performer options; run two commands."
215
+ )
216
+ if bulk_input is not None:
217
+ return mutation_result(
218
+ "bulkSceneUpdate",
219
+ args.apply,
220
+ bulk_input,
221
+ lambda: queries.scene.bulk_update_scene(bulk_input),
222
+ )
223
+ if scene_lists:
224
+ return _update_scene_lists(args)
225
+ input_data = resolve_update_input(args)
226
+ return mutation_result(
227
+ "sceneUpdate",
228
+ args.apply,
229
+ input_data,
230
+ lambda: queries.scene.update_scene(input_data),
231
+ )
232
+
233
+
234
+ def identify_input(args: argparse.Namespace) -> dict:
235
+ """Build an IdentifyMetadataInput: `--input`, then the options over their fields.
236
+
237
+ The guards run before any `--source` is resolved, so they send nothing.
238
+ """
239
+ input_data = read_input_object(args.input)
240
+ if args.scene_ids is not None:
241
+ input_data["sceneIDs"] = args.scene_ids
242
+ if not (args.sources or input_data.get("sources")):
243
+ raise core.CliError(
244
+ "Pass at least one --source, or sources in --input; without sources "
245
+ "Stash identifies nothing.",
246
+ 2,
247
+ "usage_error",
248
+ )
249
+ if not (input_data.get("sceneIDs") or input_data.get("paths")):
250
+ raise core.CliError(
251
+ "Pass --scene-id, or paths in --input; without either Stash "
252
+ "identifies every scene in the library.",
253
+ 2,
254
+ "usage_error",
255
+ )
256
+ if args.sources is not None:
257
+ input_data["sources"] = [
258
+ {"source": sources.resolve_source(value, "SCENE")} for value in args.sources
259
+ ]
260
+ return input_data
261
+
262
+
263
+ def cmd_scene_identify(args: argparse.Namespace) -> object:
264
+ input_data = identify_input(args)
265
+ return job_result(
266
+ "metadataIdentify",
267
+ args.apply,
268
+ input_data,
269
+ lambda: queries.metadata.start_identify(input_data),
270
+ )
271
+
272
+
273
+ def register(subparsers: argparse._SubParsersAction) -> None:
274
+ scene = subparsers.add_parser("scene", help="Scene lookups and writes.")
275
+ scene_sub = scene.add_subparsers(dest="scene_command", required=True)
276
+
277
+ add_get_command(scene_sub, "scene", queries.scene.get_scene, example_id=1)
278
+
279
+ find = add_leaf(
280
+ scene_sub,
281
+ "find",
282
+ kind=CommandKind.READ,
283
+ func=cmd_scene_find,
284
+ help="Find scenes by exact title, exact code, or by a SceneFilterType.",
285
+ examples=(
286
+ "stashapp-cli scene find --name 'Film'",
287
+ "stashapp-cli scene find --code 'BA123'",
288
+ 'stashapp-cli scene find --filter-json \'{"title": {"value": "Film", "modifier": "INCLUDES"}}\'',
289
+ "stashapp-cli scene find --name 'Film' --filter-json '{\"organized\": false}' --page 2",
290
+ ),
291
+ )
292
+ add_find_options(find, FIND)
293
+ find.add_argument(
294
+ "--code",
295
+ default=None,
296
+ metavar="CODE",
297
+ help="Match the scene code exactly, ignoring case.",
298
+ )
299
+ find.epilog = f"{find.epilog}" + (
300
+ "\n\nA scene code is unique only within one studio, so --code can match\n"
301
+ "scenes of several studios; add a studios criterion in --filter-json to\n"
302
+ "narrow it. --code follows the same rules as --name: it is added to the\n"
303
+ "filter and conflicts with a code criterion or a top-level AND, OR, or NOT."
304
+ )
305
+
306
+ update = add_leaf(
307
+ scene_sub,
308
+ "update",
309
+ kind=CommandKind.WRITE,
310
+ func=cmd_scene_update,
311
+ help="Update a scene (dry-run by default; see global --apply).",
312
+ examples=(
313
+ 'stashapp-cli scene update 812 --input \'{"title": "Film"}\'',
314
+ 'stashapp-cli scene update --input \'{"id": "1", "title": "Film"}\'',
315
+ "stashapp-cli scene update --input @scene.json --apply",
316
+ "stashapp-cli scene update 812 --add-tag-id 55 --apply",
317
+ "stashapp-cli scene update 812 --add-group 5:3",
318
+ "stashapp-cli scene update 812 --add-stash-id https://box.example/graphql:UUID",
319
+ ),
320
+ )
321
+ add_update_target(update, "SceneUpdateInput")
322
+ add_list_options(update, LIST_FIELDS)
323
+ update.add_argument(
324
+ "--add-group",
325
+ action="append",
326
+ default=None,
327
+ metavar="GROUP_ID[:SCENE_INDEX]",
328
+ help="Add the scene to a group, or set its scene number there. "
329
+ + LIST_OPTION_NOTE,
330
+ )
331
+ update.add_argument(
332
+ "--remove-group",
333
+ action="append",
334
+ default=None,
335
+ metavar="GROUP_ID",
336
+ help="Remove the scene from a group. " + LIST_OPTION_NOTE,
337
+ )
338
+ update.add_argument(
339
+ "--add-stash-id",
340
+ action="append",
341
+ default=None,
342
+ metavar="ENDPOINT:STASH_ID",
343
+ help="Link a stash ID; splits on the last ':'. ENDPOINT is a configured "
344
+ "stash-box endpoint or name. " + LIST_OPTION_NOTE,
345
+ )
346
+ update.add_argument(
347
+ "--remove-stash-id",
348
+ action="append",
349
+ default=None,
350
+ metavar="ENDPOINT",
351
+ help="Remove every stash ID for a stash-box endpoint or name. "
352
+ + LIST_OPTION_NOTE,
353
+ )
354
+
355
+ identify = add_leaf(
356
+ scene_sub,
357
+ "identify",
358
+ kind=CommandKind.WRITE,
359
+ func=cmd_scene_identify,
360
+ help="Start an identify job for scenes and print its job id.",
361
+ examples=(
362
+ (
363
+ "stashapp-cli scene identify --scene-id 812 --source ExampleBox "
364
+ "--source ExampleScraper"
365
+ ),
366
+ "stashapp-cli scene identify --scene-id 812 --source scraper:ExampleScraper --apply",
367
+ (
368
+ 'stashapp-cli scene identify --source ExampleBox --input \'{"paths": '
369
+ '["/media/new"], "options": {"setOrganized": false}}\''
370
+ ),
371
+ ),
372
+ )
373
+ identify.description = (
374
+ "Start a Stash identify job (metadataIdentify) and print its job_id.\n"
375
+ "Wait for it with `stashapp-cli job wait JOB_ID`.\n"
376
+ "\n"
377
+ "Each --source is a configured stash-box name or endpoint, or an\n"
378
+ "installed scene scraper id; add scraper: or stashbox: when a value is\n"
379
+ "both. Sources are tried in the order given and are checked before the\n"
380
+ "dry run. Sources and targets are required: pass --scene-id, or paths\n"
381
+ "in --input. Per-source options can only be set through --input."
382
+ )
383
+ identify.add_argument(
384
+ "--scene-id",
385
+ dest="scene_ids",
386
+ action="append",
387
+ default=None,
388
+ metavar="ID",
389
+ help="Scene to identify; repeatable. Replaces sceneIDs in --input.",
390
+ )
391
+ identify.add_argument(
392
+ "--source",
393
+ dest="sources",
394
+ action="append",
395
+ default=None,
396
+ metavar="S",
397
+ help="Source to try, in order; repeatable. Replaces sources in --input.",
398
+ )
399
+ identify.add_argument(
400
+ "--input",
401
+ default=None,
402
+ help=(
403
+ "IdentifyMetadataInput as JSON, @file, or @-; options override its fields."
404
+ ),
405
+ )
@@ -0,0 +1,133 @@
1
+ """scrape commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from .. import core, images, queries, sources
9
+ from ._common import CommandKind, add_leaf
10
+
11
+
12
+ def cmd_scrape_group_url(args: argparse.Namespace) -> object:
13
+ image_dir = _image_dir(args)
14
+ return images.replace_images(queries.scrape.scrape_group_url(args.url), image_dir)
15
+
16
+
17
+ def cmd_scrape_scene_url(args: argparse.Namespace) -> object:
18
+ image_dir = _image_dir(args)
19
+ return images.replace_images(queries.scrape.scrape_scene_url(args.url), image_dir)
20
+
21
+
22
+ def cmd_scrape_performer_url(args: argparse.Namespace) -> object:
23
+ image_dir = _image_dir(args)
24
+ return images.replace_images(
25
+ queries.scrape.scrape_performer_url(args.url), image_dir
26
+ )
27
+
28
+
29
+ def cmd_scrape_sources(args: argparse.Namespace) -> object:
30
+ types = [args.type.upper()] if args.type else ["SCENE", "PERFORMER", "GROUP"]
31
+ return queries.sources.list_source_details(types)
32
+
33
+
34
+ def cmd_scrape_scene(args: argparse.Namespace) -> object:
35
+ image_dir = _image_dir(args)
36
+ source = sources.resolve_source(args.source, "SCENE")
37
+ input_data = {"scene_id": args.scene_id} if args.scene_id else {"query": args.query}
38
+ return images.replace_images(
39
+ queries.scrape.scrape_single_scene(source, input_data), image_dir
40
+ )
41
+
42
+
43
+ def add_image_dir(parser: argparse.ArgumentParser) -> None:
44
+ parser.add_argument(
45
+ "--image-dir", metavar="DIR", help="Write scraped data URI images to DIR."
46
+ )
47
+
48
+
49
+ def _image_dir(args: argparse.Namespace) -> Path | None:
50
+ if not args.image_dir:
51
+ return None
52
+ path = Path(args.image_dir)
53
+ if path.exists() and not path.is_dir():
54
+ raise core.CliError(
55
+ f"--image-dir must be a directory: {path}", 2, "usage_error"
56
+ )
57
+ return path
58
+
59
+
60
+ def register(subparsers: argparse._SubParsersAction) -> None:
61
+ scrape = subparsers.add_parser(
62
+ "scrape", help="Delegate to Stash's installed scrapers."
63
+ )
64
+ scrape_sub = scrape.add_subparsers(dest="scrape_command", required=True)
65
+
66
+ source_list = add_leaf(
67
+ scrape_sub,
68
+ "sources",
69
+ kind=CommandKind.READ,
70
+ func=cmd_scrape_sources,
71
+ help="List configured stash-boxes and installed scrape sources.",
72
+ examples=("stashapp-cli scrape sources --type scene",),
73
+ )
74
+ source_list.add_argument("--type", choices=("scene", "performer", "group"))
75
+
76
+ scene = add_leaf(
77
+ scrape_sub,
78
+ "scene",
79
+ kind=CommandKind.READ,
80
+ func=cmd_scrape_scene,
81
+ help="Scrape one scene through a configured source.",
82
+ examples=(
83
+ "stashapp-cli scrape scene --source ExampleBox --scene-id 812",
84
+ "stashapp-cli scrape scene --source ExampleBox --query 'Film Title'",
85
+ ),
86
+ )
87
+ scene.description = (
88
+ "Scrape one scene (scrapeSingleScene) and print the list of matches.\n"
89
+ "\n"
90
+ "--source is a configured stash-box name or endpoint, or an installed\n"
91
+ "scene scraper id; add scraper: or stashbox: when a value is both.\n"
92
+ "Run `stashapp-cli scrape sources --type scene` to list the sources and\n"
93
+ "their scrape types. With --scene-id, a scraper source must support\n"
94
+ "FRAGMENT scrapes."
95
+ )
96
+ scene.add_argument("--source", required=True, help="Stash-box name or scraper ID.")
97
+ selectors = scene.add_mutually_exclusive_group(required=True)
98
+ selectors.add_argument("--scene-id", metavar="ID")
99
+ selectors.add_argument("--query", metavar="TEXT")
100
+ add_image_dir(scene)
101
+
102
+ group_url = add_leaf(
103
+ scrape_sub,
104
+ "group-url",
105
+ kind=CommandKind.READ,
106
+ func=cmd_scrape_group_url,
107
+ help="Scrape a URL for group data.",
108
+ examples=("stashapp-cli scrape group-url https://example.com/group",),
109
+ )
110
+ group_url.add_argument("url")
111
+ add_image_dir(group_url)
112
+
113
+ scene_url = add_leaf(
114
+ scrape_sub,
115
+ "scene-url",
116
+ kind=CommandKind.READ,
117
+ func=cmd_scrape_scene_url,
118
+ help="Scrape a URL for full scene data.",
119
+ examples=("stashapp-cli scrape scene-url https://example.com/scene",),
120
+ )
121
+ scene_url.add_argument("url")
122
+ add_image_dir(scene_url)
123
+
124
+ performer_url = add_leaf(
125
+ scrape_sub,
126
+ "performer-url",
127
+ kind=CommandKind.READ,
128
+ func=cmd_scrape_performer_url,
129
+ help="Scrape a URL for performer data.",
130
+ examples=("stashapp-cli scrape performer-url https://example.com/performer",),
131
+ )
132
+ performer_url.add_argument("url")
133
+ add_image_dir(performer_url)