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,368 @@
1
+ """performer commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from .. import core, queries
8
+ from ._common import (
9
+ ALIASES,
10
+ TAG_IDS,
11
+ URLS,
12
+ CommandKind,
13
+ FindSpec,
14
+ add_find_options,
15
+ add_get_command,
16
+ add_leaf,
17
+ add_list_options,
18
+ add_update_target,
19
+ bulk_update_input,
20
+ find_filter,
21
+ mutation_result,
22
+ read_json_arg,
23
+ require_force,
24
+ require_list_target,
25
+ resolve_update_input,
26
+ )
27
+
28
+ FIND = FindSpec("performers", "name", match_aliases=True, sort="name")
29
+
30
+
31
+ def cmd_performer_find(args: argparse.Namespace) -> object:
32
+ return queries.performer.find_performers(
33
+ find_filter(args, FIND), args.page, args.per_page
34
+ )
35
+
36
+
37
+ def cmd_performer_create(args: argparse.Namespace) -> object:
38
+ urls = args.url or []
39
+ input_data = {"name": args.name, "gender": args.gender, "urls": urls}
40
+ return mutation_result(
41
+ "performerCreate",
42
+ args.apply,
43
+ input_data,
44
+ lambda: queries.performer.create_performer(args.name, args.gender, urls),
45
+ )
46
+
47
+
48
+ LIST_FIELDS = (TAG_IDS, URLS, ALIASES)
49
+
50
+
51
+ def cmd_performer_update(args: argparse.Namespace) -> object:
52
+ bulk_input = bulk_update_input(args, LIST_FIELDS)
53
+ if bulk_input is not None:
54
+ require_list_target(args)
55
+ return mutation_result(
56
+ "bulkPerformerUpdate",
57
+ args.apply,
58
+ bulk_input,
59
+ lambda: queries.performer.bulk_update_performer(bulk_input),
60
+ )
61
+ input_data = resolve_update_input(args)
62
+ return mutation_result(
63
+ "performerUpdate",
64
+ args.apply,
65
+ input_data,
66
+ lambda: queries.performer.update_performer(input_data),
67
+ )
68
+
69
+
70
+ def _alias_key(value: str) -> str:
71
+ return value.strip().casefold()
72
+
73
+
74
+ def _stash_id_key(stash_id: dict) -> tuple[str, str]:
75
+ return (stash_id["endpoint"], stash_id["stash_id"])
76
+
77
+
78
+ def _kept(destination: dict, values: dict, field: str) -> list:
79
+ """The destination's value for `field` after the merge's partial update."""
80
+ value = values[field] if field in values else destination.get(field)
81
+ if value is None:
82
+ return []
83
+ return [value] if field == "name" else list(value)
84
+
85
+
86
+ def lost_values(destination: dict, sources: list[dict], values: dict) -> list[dict]:
87
+ """Report, per source, the aliases, URLs, and stash IDs the merge would drop.
88
+
89
+ Kept values come from `values` when it sets a field, because a list in
90
+ `values` replaces the destination's list, and from `destination` otherwise.
91
+ """
92
+ kept_aliases = {
93
+ _alias_key(alias)
94
+ for alias in _kept(destination, values, "name")
95
+ + _kept(destination, values, "alias_list")
96
+ }
97
+ kept_urls = set(_kept(destination, values, "urls"))
98
+ kept_stash_ids = {
99
+ _stash_id_key(stash_id) for stash_id in _kept(destination, values, "stash_ids")
100
+ }
101
+
102
+ report = []
103
+ for source in sources:
104
+ aliases: list[str] = []
105
+ seen_aliases: set[str] = set()
106
+ for alias in [source.get("name"), *(source.get("alias_list") or [])]:
107
+ if not alias:
108
+ continue
109
+ key = _alias_key(alias)
110
+ if key in kept_aliases or key in seen_aliases:
111
+ continue
112
+ seen_aliases.add(key)
113
+ aliases.append(alias)
114
+
115
+ urls: list[str] = []
116
+ for url in source.get("urls") or []:
117
+ if url not in kept_urls and url not in urls:
118
+ urls.append(url)
119
+
120
+ stash_ids: list[dict] = []
121
+ seen_stash_ids: set[tuple[str, str]] = set()
122
+ for stash_id in source.get("stash_ids") or []:
123
+ key = _stash_id_key(stash_id)
124
+ if key in kept_stash_ids or key in seen_stash_ids:
125
+ continue
126
+ seen_stash_ids.add(key)
127
+ stash_ids.append({"endpoint": key[0], "stash_id": key[1]})
128
+
129
+ report.append(
130
+ {
131
+ "id": source["id"],
132
+ "name": source.get("name"),
133
+ "lost": {"aliases": aliases, "urls": urls, "stash_ids": stash_ids},
134
+ }
135
+ )
136
+ return report
137
+
138
+
139
+ def _performer_id(value: object, option: str) -> str:
140
+ """`value` as Stash writes a performer ID, so `03` and `3` compare equal."""
141
+ text = str(value).strip()
142
+ if isinstance(value, bool) or not text.isdecimal():
143
+ raise core.CliError(
144
+ f"{option} must be a performer ID (a non-negative integer), got {value!r}.",
145
+ 2,
146
+ "usage_error",
147
+ )
148
+ return str(int(text))
149
+
150
+
151
+ def _is_str_list(value: object) -> bool:
152
+ return isinstance(value, list) and all(isinstance(item, str) for item in value)
153
+
154
+
155
+ def _is_stash_id_list(value: object) -> bool:
156
+ return isinstance(value, list) and all(
157
+ isinstance(item, dict)
158
+ and isinstance(item.get("endpoint"), str)
159
+ and isinstance(item.get("stash_id"), str)
160
+ for item in value
161
+ )
162
+
163
+
164
+ # The `--values` fields the loss report reads, with the shape each must have.
165
+ _VALUES_FIELD_CHECKS = {
166
+ "name": (lambda value: isinstance(value, str), "a string"),
167
+ "alias_list": (_is_str_list, "a list of strings"),
168
+ "urls": (_is_str_list, "a list of strings"),
169
+ "stash_ids": (
170
+ _is_stash_id_list,
171
+ "a list of objects with string endpoint and stash_id",
172
+ ),
173
+ }
174
+
175
+
176
+ def _check_values_fields(values: dict) -> None:
177
+ for field, (is_valid, expected) in _VALUES_FIELD_CHECKS.items():
178
+ if (
179
+ field in values
180
+ and values[field] is not None
181
+ and not is_valid(values[field])
182
+ ):
183
+ raise core.CliError(
184
+ f"--values field {field} must be {expected}.", 2, "usage_error"
185
+ )
186
+
187
+
188
+ def _merge_values(raw: str | None, destination_id: str) -> dict | None:
189
+ """Parse and check `--values`, filling its required id with the destination."""
190
+ if raw is None:
191
+ return None
192
+ values = read_json_arg(raw)
193
+ if not isinstance(values, dict):
194
+ raise core.CliError(
195
+ "--values must be a PerformerUpdateInput JSON object.", 2, "usage_error"
196
+ )
197
+ if "url" in values:
198
+ raise core.CliError(
199
+ "--values must not set the legacy url field; performerMerge rejects "
200
+ "it. Use urls.",
201
+ 2,
202
+ "usage_error",
203
+ )
204
+ _check_values_fields(values)
205
+ if "id" in values:
206
+ if _performer_id(values["id"], "--values id") != destination_id:
207
+ raise core.CliError(
208
+ f"Destination {destination_id} does not match id {values['id']} "
209
+ "in --values.",
210
+ 2,
211
+ "usage_error",
212
+ )
213
+ else:
214
+ values["id"] = destination_id
215
+ return values
216
+
217
+
218
+ def cmd_performer_merge(args: argparse.Namespace) -> object:
219
+ destination_id = _performer_id(args.destination, "--destination")
220
+ source_ids = list(
221
+ dict.fromkeys(_performer_id(source, "--source") for source in args.source)
222
+ )
223
+ if destination_id in source_ids:
224
+ raise core.CliError(
225
+ f"Destination {destination_id} is also a --source; a performer cannot "
226
+ "be merged into itself.",
227
+ 2,
228
+ "usage_error",
229
+ )
230
+ values = _merge_values(args.values, destination_id)
231
+
232
+ performers = {
233
+ performer["id"]: performer
234
+ for performer in queries.performer.find_performers_by_ids(
235
+ [destination_id, *source_ids]
236
+ )
237
+ }
238
+ destination = performers[destination_id]
239
+ sources = lost_values(
240
+ destination,
241
+ [performers[source_id] for source_id in source_ids],
242
+ values or {},
243
+ )
244
+
245
+ input_data: dict = {"source": source_ids, "destination": destination_id}
246
+ if values is not None:
247
+ input_data["values"] = values
248
+ if not args.apply:
249
+ return {
250
+ "applied": False,
251
+ "operation": "performerMerge",
252
+ "input": input_data,
253
+ "destination": {"id": destination["id"], "name": destination.get("name")},
254
+ "sources": sources,
255
+ }
256
+ require_force(args, ["performerMerge"])
257
+ return {
258
+ "applied": True,
259
+ "operation": "performerMerge",
260
+ "id": queries.performer.merge_performers(input_data),
261
+ "sources": sources,
262
+ }
263
+
264
+
265
+ def register(subparsers: argparse._SubParsersAction) -> None:
266
+ performer = subparsers.add_parser("performer", help="Performer lookups and writes.")
267
+ performer_sub = performer.add_subparsers(dest="performer_command", required=True)
268
+
269
+ add_get_command(
270
+ performer_sub, "performer", queries.performer.get_performer, example_id=8
271
+ )
272
+
273
+ find = add_leaf(
274
+ performer_sub,
275
+ "find",
276
+ kind=CommandKind.READ,
277
+ func=cmd_performer_find,
278
+ help="Find performers by exact name or alias, or by a PerformerFilterType.",
279
+ examples=(
280
+ "stashapp-cli performer find --name 'Jane Doe'",
281
+ 'stashapp-cli performer find --filter-json \'{"gender": {"value": "FEMALE", "modifier": "EQUALS"}}\'',
282
+ "stashapp-cli performer find --name 'Jane Doe' --filter-json '{\"favorite\": true}'",
283
+ "stashapp-cli performer find --page 2 --per-page 100",
284
+ ),
285
+ )
286
+ add_find_options(find, FIND)
287
+
288
+ create = add_leaf(
289
+ performer_sub,
290
+ "create",
291
+ kind=CommandKind.WRITE,
292
+ func=cmd_performer_create,
293
+ help="Create a performer (dry-run by default; see global --apply).",
294
+ examples=(
295
+ "stashapp-cli performer create 'Jane Doe' --gender FEMALE",
296
+ "stashapp-cli performer create 'Jane Doe' --apply",
297
+ ),
298
+ )
299
+ create.add_argument("name")
300
+ create.add_argument("--gender", default=None)
301
+ create.add_argument(
302
+ "--url", action="append", default=None, help="May be given more than once."
303
+ )
304
+
305
+ update = add_leaf(
306
+ performer_sub,
307
+ "update",
308
+ kind=CommandKind.WRITE,
309
+ func=cmd_performer_update,
310
+ help="Update a performer (dry-run by default; see global --apply).",
311
+ examples=(
312
+ "stashapp-cli performer update 1 --input '{\"urls\": []}'",
313
+ 'stashapp-cli performer update --input \'{"id": "1", "urls": []}\'',
314
+ "stashapp-cli --apply performer update --input @performer.json",
315
+ "stashapp-cli performer update 1 --add-alias 'Jay D' --add-tag-id 55",
316
+ "stashapp-cli performer update 1 --remove-url https://old.example/jane --apply",
317
+ ),
318
+ )
319
+ add_update_target(update, "PerformerUpdateInput")
320
+ add_list_options(update, LIST_FIELDS)
321
+
322
+ merge = add_leaf(
323
+ performer_sub,
324
+ "merge",
325
+ kind=CommandKind.DESTRUCTIVE,
326
+ func=cmd_performer_merge,
327
+ help=(
328
+ "Merge source performers into a destination and destroy the sources "
329
+ "(dry-run by default; needs --apply --force)."
330
+ ),
331
+ examples=(
332
+ "stashapp-cli performer merge --destination 3 --source 7 --source 9",
333
+ (
334
+ "stashapp-cli performer merge --destination 3 --source 7 "
335
+ '--values \'{"alias_list": ["JD", "Jane D"]}\''
336
+ ),
337
+ (
338
+ "stashapp-cli performer merge --destination 3 --source 7 "
339
+ "--values @values.json --apply --force"
340
+ ),
341
+ ),
342
+ )
343
+ merge.add_argument(
344
+ "--destination", required=True, metavar="ID", help="Performer to keep."
345
+ )
346
+ merge.add_argument(
347
+ "--source",
348
+ action="append",
349
+ required=True,
350
+ metavar="ID",
351
+ help="Performer to merge and destroy; may be given more than once.",
352
+ )
353
+ merge.add_argument(
354
+ "--values",
355
+ default=None,
356
+ help=(
357
+ "PerformerUpdateInput as JSON, @file, or @- whose fields override "
358
+ "the destination; its id defaults to the destination."
359
+ ),
360
+ )
361
+ merge.epilog = f"{merge.epilog}" + (
362
+ "\n\nThe dry run reads every performer and lists, per source, the "
363
+ "aliases (including\nits name), URLs, and stash IDs that the destination "
364
+ "would not keep, so the\nmerge would lose them. List fields in --values "
365
+ "(alias_list, urls, stash_ids)\nreplace the destination's list, so pass "
366
+ "the full list. To keep a lost value,\nadd it to --values and re-run "
367
+ "the dry run; the lost lists are empty when\nnothing would be lost."
368
+ )
@@ -0,0 +1,92 @@
1
+ """scan commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from .. import core, queries
8
+ from ._common import CommandKind, add_leaf, job_result, read_input_object
9
+
10
+
11
+ def scan_input(args: argparse.Namespace) -> dict:
12
+ """Build a ScanMetadataInput: `--input`, then the options over their fields."""
13
+ input_data = read_input_object(args.input)
14
+ if args.paths:
15
+ input_data["paths"] = args.paths
16
+ if args.phashes:
17
+ input_data["scanGeneratePhashes"] = True
18
+ if args.covers:
19
+ input_data["scanGenerateCovers"] = True
20
+ if args.rescan:
21
+ input_data["rescan"] = True
22
+ if not input_data.get("paths"):
23
+ raise core.CliError(
24
+ "Pass at least one PATH, or paths in --input.", 2, "usage_error"
25
+ )
26
+ return input_data
27
+
28
+
29
+ def cmd_scan_run(args: argparse.Namespace) -> object:
30
+ input_data = scan_input(args)
31
+ return job_result(
32
+ "metadataScan",
33
+ args.apply,
34
+ input_data,
35
+ lambda: queries.metadata.start_scan(input_data),
36
+ )
37
+
38
+
39
+ def register(subparsers: argparse._SubParsersAction) -> None:
40
+ scan = subparsers.add_parser("scan", help="Start Stash scan jobs.")
41
+ scan_sub = scan.add_subparsers(dest="scan_command", required=True)
42
+
43
+ run = add_leaf(
44
+ scan_sub,
45
+ "run",
46
+ kind=CommandKind.WRITE,
47
+ func=cmd_scan_run,
48
+ help="Start a scan job for library paths and print its job id.",
49
+ examples=(
50
+ "stashapp-cli scan run /media/new --phashes",
51
+ "stashapp-cli scan run /media/new --phashes --covers --apply",
52
+ (
53
+ 'stashapp-cli scan run --input \'{"paths": ["/media/new"], '
54
+ '"scanGenerateSprites": true}\''
55
+ ),
56
+ ),
57
+ )
58
+ run.description = (
59
+ "Start a Stash scan job (metadataScan) and print its job_id. Wait for\n"
60
+ "it with `stashapp-cli job wait JOB_ID`.\n"
61
+ "\n"
62
+ "Scan options that are left out are off: a scan generates phashes or\n"
63
+ "covers only when --phashes or --covers is given. A scan does not add\n"
64
+ "phashes to files that Stash already knows; use\n"
65
+ "`stashapp-cli generate run --phashes` for them."
66
+ )
67
+ run.add_argument(
68
+ "paths",
69
+ nargs="*",
70
+ metavar="PATH",
71
+ help="Paths to scan; replace paths in --input. At least one is required.",
72
+ )
73
+ run.add_argument(
74
+ "--phashes",
75
+ action="store_true",
76
+ help="Generate phashes for new files (sets scanGeneratePhashes).",
77
+ )
78
+ run.add_argument(
79
+ "--covers",
80
+ action="store_true",
81
+ help="Generate covers for new files (sets scanGenerateCovers).",
82
+ )
83
+ run.add_argument(
84
+ "--rescan",
85
+ action="store_true",
86
+ help="Rescan files even if they have not changed (sets rescan).",
87
+ )
88
+ run.add_argument(
89
+ "--input",
90
+ default=None,
91
+ help="ScanMetadataInput as JSON, @file, or @-; options override its fields.",
92
+ )