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
stashapp_cli/core.py ADDED
@@ -0,0 +1,165 @@
1
+ """Shared Stash GraphQL transport, configuration, and output primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from contextlib import contextmanager
10
+ from contextvars import ContextVar
11
+
12
+ DEFAULT_TIMEOUT = 30.0
13
+ # Prefix of every graphql_error message that `call` raises.
14
+ GRAPHQL_ERROR_PREFIX = "Stash GraphQL error: "
15
+ _active_timeout: ContextVar[float] = ContextVar(
16
+ "stash_timeout", default=DEFAULT_TIMEOUT
17
+ )
18
+
19
+
20
+ class CliError(Exception):
21
+ """An expected CLI failure with a documented process exit code."""
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ exit_code: int,
27
+ code: str | None = None,
28
+ details: dict | None = None,
29
+ ):
30
+ super().__init__(message)
31
+ self.exit_code = exit_code
32
+ # Structured fields added to the JSON error envelope beside code/message.
33
+ self.details = details or {}
34
+ self.code = code or {
35
+ 2: "usage_error",
36
+ 3: "not_found",
37
+ 4: "authentication_error",
38
+ }.get(exit_code, "internal_error")
39
+
40
+
41
+ @contextmanager
42
+ def request_timeout(seconds: float):
43
+ """Use ``seconds`` for all transport calls in this command invocation."""
44
+ token = _active_timeout.set(seconds)
45
+ try:
46
+ yield
47
+ finally:
48
+ _active_timeout.reset(token)
49
+
50
+
51
+ def current_timeout() -> float:
52
+ """Return the timeout, in seconds, active for this command invocation."""
53
+ return _active_timeout.get()
54
+
55
+
56
+ def get_server() -> str:
57
+ """Return the configured Stash server base URL, or raise an authentication error."""
58
+ server = os.environ.get("STASH_URL")
59
+ if not server:
60
+ raise CliError(
61
+ "STASH_URL is not set. Example: export STASH_URL=http://localhost:9999",
62
+ 4,
63
+ "authentication_error",
64
+ )
65
+ return server.rstrip("/")
66
+
67
+
68
+ def get_api_key() -> str:
69
+ """Return the configured Stash API key, or raise an authentication error."""
70
+ key = os.environ.get("STASH_API_KEY")
71
+ if not key:
72
+ raise CliError(
73
+ "STASH_API_KEY is not set. Generate one from Stash under "
74
+ "Settings > Security > Authentication, then "
75
+ "export STASH_API_KEY=<key>.",
76
+ 4,
77
+ "authentication_error",
78
+ )
79
+ return key
80
+
81
+
82
+ def call(query: str, variables: dict | None = None) -> dict:
83
+ """Make an authenticated GraphQL request to Stash and return the `data` payload.
84
+
85
+ Args:
86
+ query: A GraphQL query or mutation document.
87
+ variables: Optional variables for the document.
88
+
89
+ Returns:
90
+ The parsed `data` object from the GraphQL response.
91
+
92
+ Raises:
93
+ CliError: For HTTP, connection, or GraphQL-level failures with a
94
+ documented exit code.
95
+ """
96
+ url = f"{get_server()}/graphql"
97
+ body: dict[str, object] = {"query": query}
98
+ if variables is not None:
99
+ body["variables"] = variables
100
+
101
+ req = urllib.request.Request(
102
+ url, data=json.dumps(body).encode("utf-8"), method="POST"
103
+ )
104
+ req.add_header("ApiKey", get_api_key())
105
+ req.add_header("Content-Type", "application/json")
106
+ req.add_header("Accept", "application/json")
107
+
108
+ try:
109
+ with urllib.request.urlopen(req, timeout=_active_timeout.get()) as resp:
110
+ raw = resp.read()
111
+ except urllib.error.HTTPError as e:
112
+ detail = e.read().decode("utf-8", "replace")
113
+ exit_code = {401: 4, 403: 4, 404: 3}.get(e.code, 1)
114
+ code = {
115
+ 401: "authentication_error",
116
+ 403: "authentication_error",
117
+ 404: "not_found",
118
+ }.get(e.code, "transport_error")
119
+ raise CliError(
120
+ f"HTTP {e.code} calling Stash GraphQL: {detail[:500]}", exit_code, code
121
+ ) from e
122
+ except (TimeoutError, urllib.error.URLError) as e:
123
+ if isinstance(e, TimeoutError) or isinstance(e.reason, TimeoutError):
124
+ raise CliError(
125
+ f"Timed out calling Stash GraphQL after {_active_timeout.get():g} seconds",
126
+ 1,
127
+ "timeout",
128
+ ) from e
129
+ raise CliError(
130
+ f"Could not reach {url}: {e.reason}", 1, "transport_error"
131
+ ) from e
132
+
133
+ result = json.loads(raw)
134
+ if errors := result.get("errors"):
135
+ messages = "; ".join(err.get("message", str(err)) for err in errors)
136
+ raise CliError(f"{GRAPHQL_ERROR_PREFIX}{messages}", 1, "graphql_error")
137
+ return result.get("data") or {}
138
+
139
+
140
+ def not_found_error(noun: str, object_id: str) -> CliError:
141
+ """The `<noun>_not_found` error for an update of an object that is missing."""
142
+ return CliError(
143
+ f"No {noun} has id {object_id}.",
144
+ 2,
145
+ f"{noun}_not_found",
146
+ {"missing_ids": [object_id]},
147
+ )
148
+
149
+
150
+ def returned_id(operation: str, data: dict, object_id: str) -> str:
151
+ """`object_id` if the bulk `operation` result lists it; otherwise raise."""
152
+ returned = [str(item["id"]) for item in data.get(operation) or [] if item]
153
+ if object_id not in returned:
154
+ raise CliError(
155
+ f"Stash did not return {object_id} from {operation}; "
156
+ "the update may not have been applied.",
157
+ 1,
158
+ "graphql_error",
159
+ )
160
+ return object_id
161
+
162
+
163
+ def output(data) -> None:
164
+ """Pretty-print `data` as indented JSON to stdout."""
165
+ print(json.dumps(data, indent=2, ensure_ascii=False))
stashapp_cli/images.py ADDED
@@ -0,0 +1,72 @@
1
+ """Replacement of large scraped image data URIs in command results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+ from . import core
11
+
12
+ _IMAGE_KEYS = {"image", "images", "front_image", "back_image"}
13
+ _EXTENSIONS = {
14
+ "image/jpeg": "jpg",
15
+ "image/png": "png",
16
+ "image/webp": "webp",
17
+ "image/gif": "gif",
18
+ }
19
+
20
+
21
+ def _decode(value: str) -> tuple[str, bytes] | None:
22
+ try:
23
+ header, encoded = value[5:].split(",", 1)
24
+ if ";base64" not in header:
25
+ return None
26
+ media_type = header.split(";", 1)[0]
27
+ if not media_type:
28
+ return None
29
+ return media_type, base64.b64decode(encoded, validate=True)
30
+ except (ValueError, binascii.Error):
31
+ return None
32
+
33
+
34
+ def _replace_value(value: object, image_dir: Path | None) -> object:
35
+ if not isinstance(value, str) or not value.startswith("data:"):
36
+ return value
37
+ decoded = _decode(value)
38
+ if decoded is None:
39
+ return {"omitted": True, "error": "invalid_data_uri"}
40
+ media_type, data = decoded
41
+ if image_dir is None:
42
+ return {"omitted": True, "media_type": media_type, "bytes": len(data)}
43
+ name = (
44
+ f"{hashlib.sha256(data).hexdigest()[:16]}.{_EXTENSIONS.get(media_type, 'bin')}"
45
+ )
46
+ path = image_dir.resolve() / name
47
+ try:
48
+ image_dir.mkdir(parents=True, exist_ok=True)
49
+ path.write_bytes(data)
50
+ except OSError as error:
51
+ raise core.CliError(
52
+ f"Could not write image {path}: {error}", 1, "image_write_failed"
53
+ ) from error
54
+ return str(path)
55
+
56
+
57
+ def replace_images(result: object, image_dir: Path | None) -> object:
58
+ """Walk a scrape result and replace data URI values in image fields."""
59
+ if isinstance(result, list):
60
+ return [replace_images(item, image_dir) for item in result]
61
+ if not isinstance(result, dict):
62
+ return result
63
+ replaced = {}
64
+ for key, value in result.items():
65
+ if key in _IMAGE_KEYS:
66
+ if key == "images" and isinstance(value, list):
67
+ replaced[key] = [_replace_value(item, image_dir) for item in value]
68
+ else:
69
+ replaced[key] = _replace_value(value, image_dir)
70
+ else:
71
+ replaced[key] = replace_images(value, image_dir)
72
+ return replaced
@@ -0,0 +1,343 @@
1
+ """Schema check: confirm the Stash GraphQL field/type names stashapp-cli depends on.
2
+
3
+ `stashapp-cli introspect` reports missing names. For a fuller view when comparing
4
+ Stash versions, `uv run python -m stashapp_cli.introspect` prints the matching
5
+ Query and Mutation fields and every field of `DUMP_TYPES`.
6
+
7
+ Stash renamed "Movie" to "Group" at some point in its history, and different
8
+ installed versions may expose `scrapeGroupURL` vs the older `scrapeMovieURL`,
9
+ or a `groups`/`movies` field on Scene, etc. Rather than assume one naming,
10
+ this queries the live schema and reports what's actually there so the
11
+ `queries` package can be adjusted to match before any write happens.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from . import core
17
+
18
+ # Types this module dumps in full for a human comparing Stash versions --
19
+ # includes the legacy Movie-era name alongside the modern Group-era ones so
20
+ # a human can see at a glance which generation of names is live.
21
+ DUMP_TYPES: tuple[str, ...] = (
22
+ "Scene",
23
+ "SceneUpdateInput",
24
+ "BulkSceneUpdateInput",
25
+ "Group",
26
+ "GroupCreateInput",
27
+ "GroupUpdateInput",
28
+ "MovieCreateInput",
29
+ "GroupDescriptionInput",
30
+ "SceneFilterType",
31
+ "GroupFilterType",
32
+ )
33
+
34
+ # Field/type names the `queries` package's GraphQL documents actually depend on --
35
+ # checked by `stashapp-cli introspect`.
36
+ EXPECTED_SCHEMA: dict[str, tuple[str, ...]] = {
37
+ "Query": (
38
+ "findStudios",
39
+ "findStudio",
40
+ "findTags",
41
+ "findTag",
42
+ "findGroups",
43
+ "findGroup",
44
+ "findScenes",
45
+ "findPerformers",
46
+ "findPerformer",
47
+ "scrapeGroupURL",
48
+ "scrapeSceneURL",
49
+ "scrapeSingleScene",
50
+ "scrapePerformerURL",
51
+ "configuration",
52
+ "listScrapers",
53
+ "findJob",
54
+ ),
55
+ "Mutation": (
56
+ "tagCreate",
57
+ "tagUpdate",
58
+ "bulkTagUpdate",
59
+ "groupCreate",
60
+ "groupUpdate",
61
+ "sceneUpdate",
62
+ "performerCreate",
63
+ "performerUpdate",
64
+ "metadataScan",
65
+ "metadataGenerate",
66
+ "metadataIdentify",
67
+ "bulkSceneUpdate",
68
+ "bulkPerformerUpdate",
69
+ "bulkGroupUpdate",
70
+ "bulkStudioUpdate",
71
+ "studioCreate",
72
+ "studioUpdate",
73
+ "performerMerge",
74
+ "tagsDestroy",
75
+ "tagsMerge",
76
+ "backupDatabase",
77
+ "submitStashBoxFingerprints",
78
+ ),
79
+ "Scene": ("groups", "stash_ids", "files", "custom_fields", "code"),
80
+ "SceneGroup": ("group", "scene_index"),
81
+ "StashID": ("endpoint", "stash_id", "updated_at"),
82
+ # Add and remove list options.
83
+ "BulkSceneUpdateInput": ("ids", "tag_ids", "urls", "performer_ids"),
84
+ "BulkPerformerUpdateInput": ("ids", "tag_ids", "urls", "alias_list"),
85
+ "BulkGroupUpdateInput": ("ids", "tag_ids", "urls"),
86
+ "BulkStudioUpdateInput": ("ids", "tag_ids", "urls"),
87
+ "BulkTagUpdateInput": ("ids", "aliases", "parent_ids", "child_ids"),
88
+ "BulkUpdateIds": ("ids", "mode"),
89
+ "BulkUpdateStrings": ("values", "mode"),
90
+ "BulkUpdateIdMode": (),
91
+ "SceneGroupInput": ("group_id", "scene_index"),
92
+ "StashIDInput": ("endpoint", "stash_id", "updated_at"),
93
+ # Studio create, performer merge and its loss report, and backup.
94
+ "StudioCreateInput": ("name", "urls", "parent_id"),
95
+ "StudioUpdateInput": ("id", "name", "parent_id", "details", "aliases"),
96
+ "TagUpdateInput": (
97
+ "id",
98
+ "name",
99
+ "sort_name",
100
+ "description",
101
+ "favorite",
102
+ "ignore_auto_tag",
103
+ "aliases",
104
+ "image",
105
+ "custom_fields",
106
+ "parent_ids",
107
+ "child_ids",
108
+ ),
109
+ "PerformerMergeInput": ("source", "destination", "values"),
110
+ "TagsMergeInput": ("source", "destination", "values"),
111
+ "BackupDatabaseInput": ("download",),
112
+ "Performer": (
113
+ "name",
114
+ "disambiguation",
115
+ "alias_list",
116
+ "details",
117
+ "urls",
118
+ "image_path",
119
+ "custom_fields",
120
+ "stash_ids",
121
+ ),
122
+ # The find commands: the criteria that --name and --studio-id set, the
123
+ # paging and sort, and the result fields not checked elsewhere.
124
+ "StudioFilterType": ("name", "aliases", "AND"),
125
+ "PerformerFilterType": ("name", "aliases", "AND"),
126
+ "FindFilterType": ("page", "per_page", "sort", "direction"),
127
+ "Studio": (
128
+ "aliases",
129
+ "details",
130
+ "image_path",
131
+ "custom_fields",
132
+ "parent_studio",
133
+ "stash_ids",
134
+ ),
135
+ "Group": (
136
+ "aliases",
137
+ "front_image_path",
138
+ "back_image_path",
139
+ "custom_fields",
140
+ "studio",
141
+ ),
142
+ "Tag": (
143
+ "name",
144
+ "sort_name",
145
+ "aliases",
146
+ "description",
147
+ "favorite",
148
+ "ignore_auto_tag",
149
+ "image_path",
150
+ "custom_fields",
151
+ "scene_count",
152
+ "scene_marker_count",
153
+ "image_count",
154
+ "gallery_count",
155
+ "performer_count",
156
+ "studio_count",
157
+ "group_count",
158
+ "parents",
159
+ "children",
160
+ ),
161
+ # Fingerprint submission. Only fingerprint types are read, never values.
162
+ "StashBoxFingerprintSubmissionInput": ("scene_ids", "stash_box_endpoint"),
163
+ "VideoFile": ("duration", "fingerprints"),
164
+ "Fingerprint": ("type",),
165
+ # Stash-box and source lookup. StashBox.api_key exists but is never read.
166
+ "ConfigResult": ("general",),
167
+ "ConfigGeneralResult": ("stashBoxes",),
168
+ "StashBox": ("endpoint", "name"),
169
+ "Scraper": ("id", "name", "scene", "performer", "group"),
170
+ "ScraperSpec": ("supported_scrapes",),
171
+ "ScraperSourceInput": ("scraper_id", "stash_box_endpoint"),
172
+ "ScrapeSingleSceneInput": ("scene_id", "query"),
173
+ "ScrapedScene": (
174
+ "title",
175
+ "code",
176
+ "details",
177
+ "director",
178
+ "urls",
179
+ "date",
180
+ "duration",
181
+ "remote_site_id",
182
+ "image",
183
+ "studio",
184
+ "tags",
185
+ "performers",
186
+ "groups",
187
+ ),
188
+ "ScrapedStudio": (
189
+ "stored_id",
190
+ "name",
191
+ "urls",
192
+ "details",
193
+ "aliases",
194
+ "remote_site_id",
195
+ "image",
196
+ "parent",
197
+ ),
198
+ "ScrapedTag": ("stored_id", "name", "remote_site_id"),
199
+ "ScrapedPerformer": (
200
+ "stored_id",
201
+ "name",
202
+ "disambiguation",
203
+ "gender",
204
+ "urls",
205
+ "aliases",
206
+ "birthdate",
207
+ "death_date",
208
+ "country",
209
+ "ethnicity",
210
+ "hair_color",
211
+ "eye_color",
212
+ "height",
213
+ "weight",
214
+ "measurements",
215
+ "fake_tits",
216
+ "penis_length",
217
+ "circumcised",
218
+ "career_start",
219
+ "career_end",
220
+ "tattoos",
221
+ "piercings",
222
+ "details",
223
+ "remote_site_id",
224
+ "tags",
225
+ "images",
226
+ ),
227
+ "ScrapedGroup": (
228
+ "stored_id",
229
+ "name",
230
+ "aliases",
231
+ "date",
232
+ "duration",
233
+ "rating",
234
+ "director",
235
+ "synopsis",
236
+ "urls",
237
+ "studio",
238
+ "tags",
239
+ "front_image",
240
+ "back_image",
241
+ ),
242
+ # Background jobs: scan run, generate run, scene identify, job get/wait.
243
+ "Job": (
244
+ "id",
245
+ "status",
246
+ "progress",
247
+ "description",
248
+ "error",
249
+ "addTime",
250
+ "startTime",
251
+ "endTime",
252
+ ),
253
+ "FindJobInput": ("id",),
254
+ "ScanMetadataInput": (
255
+ "paths",
256
+ "rescan",
257
+ "scanGenerateCovers",
258
+ "scanGeneratePhashes",
259
+ ),
260
+ "GenerateMetadataInput": ("covers", "phashes", "sceneIDs", "paths"),
261
+ "IdentifyMetadataInput": ("sources", "sceneIDs", "paths"),
262
+ "IdentifySourceInput": ("source",),
263
+ "JobStatus": (),
264
+ # These types appear directly in the CLI's GraphQL documents. Empty
265
+ # tuples mean the type's existence, rather than particular fields, matters.
266
+ "SceneFilterType": ("title", "code", "AND"),
267
+ "GroupFilterType": ("name", "studios", "AND"),
268
+ "TagFilterType": ("name", "aliases", "AND"),
269
+ "GroupCreateInput": (),
270
+ "GroupUpdateInput": (),
271
+ "SceneUpdateInput": ("groups", "stash_ids"),
272
+ "PerformerUpdateInput": (),
273
+ "GenderEnum": (),
274
+ "ScrapeContentType": (),
275
+ "ScrapeType": (),
276
+ }
277
+
278
+ _TYPE_FIELDS_QUERY = """
279
+ query TypeFields($name: String!) {
280
+ __type(name: $name) {
281
+ name
282
+ kind
283
+ fields { name }
284
+ inputFields { name }
285
+ }
286
+ }
287
+ """
288
+
289
+
290
+ def type_info(type_name: str) -> dict | None:
291
+ data = core.call(_TYPE_FIELDS_QUERY, {"name": type_name})
292
+ return data.get("__type")
293
+
294
+
295
+ def _names(info: dict | None) -> list[str]:
296
+ if not info:
297
+ return []
298
+ return [f["name"] for f in (info.get("fields") or info.get("inputFields") or [])]
299
+
300
+
301
+ def missing_schema_names() -> list[str]:
302
+ """Return "Type.field" names in EXPECTED_SCHEMA absent from the live schema."""
303
+ missing = []
304
+ for type_name, field_names in EXPECTED_SCHEMA.items():
305
+ info = type_info(type_name)
306
+ if not info:
307
+ if not field_names:
308
+ missing.append(type_name)
309
+ continue
310
+ missing.extend(f"{type_name}.{field_name}" for field_name in field_names)
311
+ continue
312
+ present = set(_names(info))
313
+ for field_name in field_names:
314
+ if field_name not in present:
315
+ missing.append(f"{type_name}.{field_name}")
316
+ return missing
317
+
318
+
319
+ def main() -> None:
320
+ print("Checking Stash GraphQL schema at", core.get_server(), "\n")
321
+
322
+ query_fields = _names(type_info("Query"))
323
+ mutation_fields = _names(type_info("Mutation"))
324
+
325
+ print("Query fields matching group/movie/scrape:")
326
+ for name in sorted(query_fields):
327
+ if any(kw in name.lower() for kw in ("group", "movie", "scrape")):
328
+ print(f" - {name}")
329
+
330
+ print("\nMutation fields matching group/movie/scene/tag:")
331
+ for name in sorted(mutation_fields):
332
+ if any(kw in name.lower() for kw in ("group", "movie", "scene", "tag")):
333
+ print(f" - {name}")
334
+
335
+ for type_name in DUMP_TYPES:
336
+ info = type_info(type_name)
337
+ print(f"\n{type_name}: {'(not found)' if info is None else ''}")
338
+ for name in _names(info):
339
+ print(f" - {name}")
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()
stashapp_cli/py.typed ADDED
File without changes
@@ -0,0 +1,40 @@
1
+ """Stash GraphQL documents and thin wrapper functions, one submodule per noun.
2
+
3
+ Field/type names here assume a modern Stash (post Movie->Group rename):
4
+ `scrapeGroupURL`, `groupCreate`/`groupUpdate`, and a `groups` field on
5
+ Scene/SceneUpdateInput. Run `uv run stashapp-cli introspect` against your
6
+ instance BEFORE the first write; it exits nonzero and lists any missing
7
+ names -- if your Stash version differs (e.g. still
8
+ `scrapeMovieURL`/`movieCreate`/`movies`), every query lives in this package.
9
+
10
+ Callers use the submodules, for example `queries.tag.find_tag`. This package
11
+ does not re-export their names.
12
+ """
13
+
14
+ from . import (
15
+ backup,
16
+ group,
17
+ job,
18
+ metadata,
19
+ performer,
20
+ scene,
21
+ scrape,
22
+ sources,
23
+ stashbox,
24
+ studio,
25
+ tag,
26
+ )
27
+
28
+ __all__ = [
29
+ "backup",
30
+ "group",
31
+ "job",
32
+ "metadata",
33
+ "performer",
34
+ "scene",
35
+ "scrape",
36
+ "sources",
37
+ "stashbox",
38
+ "studio",
39
+ "tag",
40
+ ]
@@ -0,0 +1,27 @@
1
+ """Read one object by ID through Stash's singular `find<Noun>` queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .. import core
8
+ from ._image_flags import add_image_flags
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ObjectSpec:
13
+ """How one noun's `get` differs from the others."""
14
+
15
+ query_field: str # "findStudio", "findPerformer", "findGroup", "findTag"
16
+ fields: str # the noun's field fragment, shared with its find query
17
+ image_flags: dict[str, str] # path field -> has_* flag, as in find
18
+
19
+
20
+ def get_object(spec: ObjectSpec, object_id: str) -> dict | None:
21
+ """The object with `object_id`, with its image flags, or `None` if missing.
22
+
23
+ Stash's singular queries return `null` for a missing ID.
24
+ """
25
+ query = f"query Get($id: ID!) {{ {spec.query_field}(id: $id) {{ {spec.fields} }} }}"
26
+ obj = core.call(query, {"id": object_id})[spec.query_field]
27
+ return None if obj is None else add_image_flags(obj, spec.image_flags)
@@ -0,0 +1,30 @@
1
+ """Derived `has_*` image flags for find results.
2
+
3
+ Stash has no field that says whether an object has an image. It marks a
4
+ missing image in two ways: a URL with a `default=true` query parameter, or
5
+ `null`. This module is the one place that reads those two markers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from urllib.parse import parse_qs, urlsplit
11
+
12
+
13
+ def _has_image(path: str | None) -> bool:
14
+ if path is None:
15
+ return False
16
+ return "true" not in parse_qs(urlsplit(path).query).get("default", [])
17
+
18
+
19
+ def add_image_flags(obj: dict, fields: dict[str, str]) -> dict:
20
+ """Set `obj[flag]` for each `path_field: flag` pair in `fields`; return `obj`."""
21
+ for path_field, flag in fields.items():
22
+ obj[flag] = _has_image(obj.get(path_field))
23
+ return obj
24
+
25
+
26
+ def add_page_image_flags(page: dict, key: str, fields: dict[str, str]) -> dict:
27
+ """Add the flags to every object in `page[key]`; return `page`."""
28
+ for obj in page[key]:
29
+ add_image_flags(obj, fields)
30
+ return page