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.
- stashapp_cli/__init__.py +2 -0
- stashapp_cli/cli.py +149 -0
- stashapp_cli/commands/__init__.py +39 -0
- stashapp_cli/commands/_common.py +440 -0
- stashapp_cli/commands/backup.py +70 -0
- stashapp_cli/commands/generate.py +88 -0
- stashapp_cli/commands/graphql.py +212 -0
- stashapp_cli/commands/group.py +137 -0
- stashapp_cli/commands/introspect.py +35 -0
- stashapp_cli/commands/job.py +122 -0
- stashapp_cli/commands/performer.py +368 -0
- stashapp_cli/commands/scan.py +92 -0
- stashapp_cli/commands/scene.py +405 -0
- stashapp_cli/commands/scrape.py +133 -0
- stashapp_cli/commands/stashbox.py +173 -0
- stashapp_cli/commands/studio.py +227 -0
- stashapp_cli/commands/tag.py +694 -0
- stashapp_cli/core.py +165 -0
- stashapp_cli/images.py +72 -0
- stashapp_cli/introspect.py +343 -0
- stashapp_cli/py.typed +0 -0
- stashapp_cli/queries/__init__.py +40 -0
- stashapp_cli/queries/_get.py +27 -0
- stashapp_cli/queries/_image_flags.py +30 -0
- stashapp_cli/queries/backup.py +20 -0
- stashapp_cli/queries/group.py +117 -0
- stashapp_cli/queries/job.py +29 -0
- stashapp_cli/queries/metadata.py +42 -0
- stashapp_cli/queries/performer.py +179 -0
- stashapp_cli/queries/scene.py +139 -0
- stashapp_cli/queries/scrape.py +183 -0
- stashapp_cli/queries/sources.py +76 -0
- stashapp_cli/queries/stashbox.py +67 -0
- stashapp_cli/queries/studio.py +145 -0
- stashapp_cli/queries/tag.py +244 -0
- stashapp_cli/sources.py +116 -0
- stashapp_cli-0.1.0.dist-info/METADATA +208 -0
- stashapp_cli-0.1.0.dist-info/RECORD +41 -0
- stashapp_cli-0.1.0.dist-info/WHEEL +4 -0
- stashapp_cli-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|