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,76 @@
1
+ """Configured stash-boxes and installed scrapers.
2
+
3
+ These documents select only a stash-box's `endpoint` and `name`. Never add
4
+ `api_key`: stashapp-cli must not read or print stash-box credentials.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .. import core
10
+
11
+ _STASH_BOXES_QUERY = """
12
+ query StashBoxes {
13
+ configuration { general { stashBoxes { endpoint name } } }
14
+ }
15
+ """
16
+
17
+ _SOURCES_QUERY = """
18
+ query Sources($types: [ScrapeContentType!]!) {
19
+ configuration { general { stashBoxes { endpoint name } } }
20
+ listScrapers(types: $types) { id }
21
+ }
22
+ """
23
+
24
+ _SOURCES_DETAIL_QUERY = """
25
+ query SourceDetails($types: [ScrapeContentType!]!) {
26
+ configuration { general { stashBoxes { endpoint name } } }
27
+ listScrapers(types: $types) {
28
+ id
29
+ name
30
+ scene { supported_scrapes }
31
+ performer { supported_scrapes }
32
+ group { supported_scrapes }
33
+ }
34
+ }
35
+ """
36
+
37
+
38
+ def _stash_boxes(data: dict) -> list[dict]:
39
+ general = (data.get("configuration") or {}).get("general") or {}
40
+ return [
41
+ {"endpoint": box["endpoint"], "name": box["name"]}
42
+ for box in general.get("stashBoxes") or []
43
+ ]
44
+
45
+
46
+ def list_stash_boxes() -> list[dict]:
47
+ """Configured stash-boxes as `{"endpoint", "name"}`, in Stash's order."""
48
+ return _stash_boxes(core.call(_STASH_BOXES_QUERY))
49
+
50
+
51
+ def list_sources(content_type: str) -> tuple[list[dict], list[str]]:
52
+ """Configured stash-boxes and the ids of scrapers for `content_type`.
53
+
54
+ `content_type` is a Stash `ScrapeContentType`, for example `SCENE`.
55
+ """
56
+ data = core.call(_SOURCES_QUERY, {"types": [content_type]})
57
+ scraper_ids = [scraper["id"] for scraper in data.get("listScrapers") or []]
58
+ return _stash_boxes(data), scraper_ids
59
+
60
+
61
+ def list_source_details(types: list[str]) -> dict:
62
+ """Configured boxes and detailed installed scraper capabilities."""
63
+ data = core.call(_SOURCES_DETAIL_QUERY, {"types": types})
64
+ scrapers = []
65
+ for scraper in data.get("listScrapers") or []:
66
+ scrapers.append(
67
+ {
68
+ "id": scraper["id"],
69
+ "name": scraper["name"],
70
+ "scene": (scraper.get("scene") or {}).get("supported_scrapes") or [],
71
+ "performer": (scraper.get("performer") or {}).get("supported_scrapes")
72
+ or [],
73
+ "group": (scraper.get("group") or {}).get("supported_scrapes") or [],
74
+ }
75
+ )
76
+ return {"stash_boxes": _stash_boxes(data), "scrapers": scrapers}
@@ -0,0 +1,67 @@
1
+ """Stash-box fingerprint submission and the scene read that plans it.
2
+
3
+ These documents never select fingerprint values or a stash-box `api_key`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ from .. import core
11
+
12
+ _SCENE_FINGERPRINTS_QUERY = """
13
+ query SceneFingerprints($ids: [ID!]) {
14
+ findScenes(ids: $ids) {
15
+ scenes {
16
+ id
17
+ stash_ids { endpoint }
18
+ files { duration fingerprints { type } }
19
+ }
20
+ }
21
+ }
22
+ """
23
+
24
+ _SUBMIT_FINGERPRINTS_MUTATION = """
25
+ mutation SubmitFingerprints($input: StashBoxFingerprintSubmissionInput!) {
26
+ submitStashBoxFingerprints(input: $input)
27
+ }
28
+ """
29
+
30
+ _SCENE_NOT_FOUND = re.compile(r"\bscene with id (\S+) not found\b", re.IGNORECASE)
31
+
32
+
33
+ def get_scene_fingerprints(scene_ids: list[str]) -> list[dict]:
34
+ """Fetch the stash-box endpoints and file fingerprint types of scenes.
35
+
36
+ Stash fails the whole read at the first missing id, so `missing_ids` in
37
+ the `scene_not_found` error holds only that id. If Stash instead returns
38
+ fewer scenes without an error, `missing_ids` lists every id it left out.
39
+ """
40
+ try:
41
+ data = core.call(_SCENE_FINGERPRINTS_QUERY, {"ids": list(scene_ids)})
42
+ except core.CliError as error:
43
+ if match := _SCENE_NOT_FOUND.search(str(error)):
44
+ raise core.CliError(
45
+ str(error),
46
+ 2,
47
+ "scene_not_found",
48
+ details={"missing_ids": [match.group(1)]},
49
+ ) from error
50
+ raise
51
+ scenes = (data.get("findScenes") or {}).get("scenes") or []
52
+ returned = {str(scene["id"]) for scene in scenes}
53
+ missing = [scene_id for scene_id in scene_ids if scene_id not in returned]
54
+ if missing:
55
+ raise core.CliError(
56
+ f"Stash returned no scene for id(s) {', '.join(missing)}.",
57
+ 2,
58
+ "scene_not_found",
59
+ details={"missing_ids": missing},
60
+ )
61
+ return scenes
62
+
63
+
64
+ def submit_fingerprints(input_data: dict) -> bool:
65
+ """Send a `StashBoxFingerprintSubmissionInput` and return Stash's result."""
66
+ data = core.call(_SUBMIT_FINGERPRINTS_MUTATION, {"input": input_data})
67
+ return data["submitStashBoxFingerprints"]
@@ -0,0 +1,145 @@
1
+ """Studio lookups and writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .. import core
8
+ from ._get import ObjectSpec, get_object
9
+ from ._image_flags import add_page_image_flags
10
+
11
+ _IMAGE_FLAGS = {"image_path": "has_image"}
12
+
13
+ _FIND_STUDIO_QUERY = """
14
+ query FindStudio($name: String!) {
15
+ findStudios(studio_filter: {name: {value: $name, modifier: EQUALS}}, filter: {per_page: 1}) {
16
+ studios { id name }
17
+ }
18
+ }
19
+ """
20
+
21
+
22
+ def find_studio_id(name: str) -> str | None:
23
+ data = core.call(_FIND_STUDIO_QUERY, {"name": name})
24
+ studios = data.get("findStudios", {}).get("studios", [])
25
+ return studios[0]["id"] if studios else None
26
+
27
+
28
+ _STUDIO_FIELDS = """
29
+ id
30
+ name
31
+ aliases
32
+ details
33
+ urls
34
+ image_path
35
+ custom_fields
36
+ parent_studio { id }
37
+ stash_ids { endpoint stash_id }
38
+ """
39
+
40
+ STUDIO = ObjectSpec("findStudio", _STUDIO_FIELDS, _IMAGE_FLAGS)
41
+
42
+
43
+ def get_studio(studio_id: str) -> dict | None:
44
+ """One studio by id, in the shape find_studios returns, or `None`."""
45
+ return get_object(STUDIO, studio_id)
46
+
47
+
48
+ _FIND_STUDIOS_QUERY = f"""
49
+ query FindStudios($studio_filter: StudioFilterType!, $page: Int!, $per_page: Int!) {{
50
+ findStudios(
51
+ studio_filter: $studio_filter
52
+ filter: {{page: $page, per_page: $per_page, sort: "name", direction: ASC}}
53
+ ) {{
54
+ count
55
+ studios {{
56
+ {_STUDIO_FIELDS}
57
+ }}
58
+ }}
59
+ }}
60
+ """
61
+
62
+
63
+ def find_studios(studio_filter: dict, page: int, per_page: int = 50) -> dict:
64
+ """One page of the studios matching a Stash studio filter.
65
+
66
+ Each studio also gets a `has_image` flag; see `_image_flags` for the rule.
67
+ """
68
+ result = core.call(
69
+ _FIND_STUDIOS_QUERY,
70
+ {"studio_filter": studio_filter, "page": page, "per_page": per_page},
71
+ )["findStudios"]
72
+ return add_page_image_flags(result, "studios", _IMAGE_FLAGS)
73
+
74
+
75
+ _CREATE_STUDIO_MUTATION = """
76
+ mutation CreateStudio($input: StudioCreateInput!) {
77
+ studioCreate(input: $input) {
78
+ id
79
+ name
80
+ }
81
+ }
82
+ """
83
+
84
+
85
+ def create_studio(input_data: dict) -> str:
86
+ data = core.call(_CREATE_STUDIO_MUTATION, {"input": input_data})
87
+ return data["studioCreate"]["id"]
88
+
89
+
90
+ _UPDATE_STUDIO_MUTATION = """
91
+ mutation UpdateStudio($input: StudioUpdateInput!) {
92
+ studioUpdate(input: $input) {
93
+ id
94
+ name
95
+ }
96
+ }
97
+ """
98
+
99
+
100
+ def update_studio(input_data: dict) -> str:
101
+ data = core.call(_UPDATE_STUDIO_MUTATION, {"input": input_data})
102
+ return data["studioUpdate"]["id"]
103
+
104
+
105
+ _BULK_UPDATE_STUDIO_MUTATION = """
106
+ mutation BulkUpdateStudio($input: BulkStudioUpdateInput!) {
107
+ bulkStudioUpdate(input: $input) {
108
+ id
109
+ }
110
+ }
111
+ """
112
+
113
+ # Stash's message for a missing studio includes the id, so it is matched by
114
+ # pattern.
115
+ _STUDIO_NOT_FOUND = re.compile(r"\bstudio with id\b.*\bnot found\b", re.IGNORECASE)
116
+
117
+
118
+ def bulk_update_studio(input_data: dict) -> str:
119
+ """Send a one-studio bulkStudioUpdate and return the studio id."""
120
+ (studio_id,) = input_data["ids"]
121
+ try:
122
+ data = core.call(_BULK_UPDATE_STUDIO_MUTATION, {"input": input_data})
123
+ except core.CliError as error:
124
+ if error.code == "graphql_error" and _STUDIO_NOT_FOUND.search(str(error)):
125
+ raise core.not_found_error("studio", studio_id) from error
126
+ raise
127
+ return core.returned_id("bulkStudioUpdate", data, studio_id)
128
+
129
+
130
+ _GET_STUDIO_ALIASES_QUERY = """
131
+ query GetStudioAliases($id: ID!) {
132
+ findStudio(id: $id) {
133
+ id
134
+ aliases
135
+ }
136
+ }
137
+ """
138
+
139
+
140
+ def get_studio_aliases(studio_id: str) -> list[str] | None:
141
+ """The studio's aliases, or `None` when there is no such studio."""
142
+ studio = core.call(_GET_STUDIO_ALIASES_QUERY, {"id": studio_id})["findStudio"]
143
+ if studio is None:
144
+ return None
145
+ return studio.get("aliases") or []
@@ -0,0 +1,244 @@
1
+ """Tag lookups and creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .. import core
8
+ from ._get import ObjectSpec, get_object
9
+ from ._image_flags import add_page_image_flags
10
+
11
+ _IMAGE_FLAGS = {"image_path": "has_image"}
12
+
13
+ # Matches the tag's name or any of its aliases. Stash rejects an alias that
14
+ # equals another tag's name, so at most one tag matches each way; per_page 2
15
+ # lets find_tag prefer the name match if both somehow exist.
16
+ _FIND_TAG_QUERY = """
17
+ query FindTag($name: String!) {
18
+ findTags(
19
+ tag_filter: {
20
+ name: {value: $name, modifier: EQUALS}
21
+ OR: {aliases: {value: $name, modifier: EQUALS}}
22
+ }
23
+ filter: {per_page: 2}
24
+ ) {
25
+ tags { id name }
26
+ }
27
+ }
28
+ """
29
+
30
+ _CREATE_TAG_MUTATION = """
31
+ mutation CreateTag($name: String!) {
32
+ tagCreate(input: {name: $name}) {
33
+ id
34
+ name
35
+ }
36
+ }
37
+ """
38
+
39
+
40
+ def find_tag(name: str) -> str | None:
41
+ """Read-only lookup by exact name or alias -- never creates the tag.
42
+
43
+ Safe to call in dry-run mode. When `name` is an alias, returns the id of
44
+ the tag that owns it.
45
+ """
46
+ data = core.call(_FIND_TAG_QUERY, {"name": name})
47
+ tags = data.get("findTags", {}).get("tags", [])
48
+ if not tags:
49
+ return None
50
+ for tag in tags:
51
+ if tag["name"].casefold() == name.casefold():
52
+ return tag["id"]
53
+ return tags[0]["id"]
54
+
55
+
56
+ _TAG_FIELDS = (
57
+ "id name sort_name aliases description favorite ignore_auto_tag image_path "
58
+ "custom_fields scene_count scene_marker_count image_count gallery_count "
59
+ "performer_count studio_count group_count parents { id name } "
60
+ "children { id name }"
61
+ )
62
+
63
+ TAG = ObjectSpec("findTag", _TAG_FIELDS, _IMAGE_FLAGS)
64
+
65
+
66
+ def get_tag(tag_id: str) -> dict | None:
67
+ """One tag by id, in the shape find_tags returns, or `None`."""
68
+ return get_object(TAG, tag_id)
69
+
70
+
71
+ _FIND_TAGS_QUERY = f"""
72
+ query FindTags($tag_filter: TagFilterType!, $page: Int!, $per_page: Int!) {{
73
+ findTags(
74
+ tag_filter: $tag_filter
75
+ filter: {{page: $page, per_page: $per_page, sort: "name", direction: ASC}}
76
+ ) {{
77
+ count
78
+ tags {{ {_TAG_FIELDS} }}
79
+ }}
80
+ }}
81
+ """
82
+
83
+
84
+ def find_tags(tag_filter: dict, page: int, per_page: int = 50) -> dict:
85
+ """One page of the tags matching a Stash tag filter.
86
+
87
+ Each tag also gets a `has_image` flag; see `_image_flags` for the rule.
88
+ """
89
+ result = core.call(
90
+ _FIND_TAGS_QUERY,
91
+ {"tag_filter": tag_filter, "page": page, "per_page": per_page},
92
+ )["findTags"]
93
+ return add_page_image_flags(result, "tags", _IMAGE_FLAGS)
94
+
95
+
96
+ def find_or_create_tag(name: str) -> str:
97
+ if tag_id := find_tag(name):
98
+ return tag_id
99
+
100
+ try:
101
+ return core.call(_CREATE_TAG_MUTATION, {"name": name})["tagCreate"]["id"]
102
+ except core.CliError as error:
103
+ # find_tag already matches aliases; this covers an alias added between
104
+ # the lookup and the create, which Stash rejects with this message.
105
+ alias_match = re.search(r"is used as alias for '(.+)'", str(error))
106
+ if alias_match and (tag_id := find_tag(alias_match.group(1))):
107
+ return tag_id
108
+ raise
109
+
110
+
111
+ _UPDATE_TAG_MUTATION = """
112
+ mutation UpdateTag($input: TagUpdateInput!) {
113
+ tagUpdate(input: $input) {
114
+ id
115
+ name
116
+ }
117
+ }
118
+ """
119
+
120
+
121
+ def update_tag(input_data: dict) -> str:
122
+ data = core.call(_UPDATE_TAG_MUTATION, {"input": input_data})
123
+ return data["tagUpdate"]["id"]
124
+
125
+
126
+ _BULK_UPDATE_TAG_MUTATION = """
127
+ mutation BulkUpdateTag($input: BulkTagUpdateInput!) {
128
+ bulkTagUpdate(input: $input) {
129
+ id
130
+ }
131
+ }
132
+ """
133
+
134
+ # Stash's message for a missing tag includes the id, so it is matched by
135
+ # pattern.
136
+ _TAG_NOT_FOUND = re.compile(r"\btag with id\b.*\bnot found\b", re.IGNORECASE)
137
+
138
+
139
+ def bulk_update_tag(input_data: dict) -> str:
140
+ """Send a one-tag bulkTagUpdate and return the tag id."""
141
+ (tag_id,) = input_data["ids"]
142
+ try:
143
+ data = core.call(_BULK_UPDATE_TAG_MUTATION, {"input": input_data})
144
+ except core.CliError as error:
145
+ if error.code == "graphql_error" and _TAG_NOT_FOUND.search(str(error)):
146
+ raise core.not_found_error("tag", tag_id) from error
147
+ raise
148
+ return core.returned_id("bulkTagUpdate", data, tag_id)
149
+
150
+
151
+ # Selects what the delete and merge reports read. `image_path` is read only to
152
+ # tell whether the tag has an image; callers must not print it.
153
+ _FIND_TAGS_BY_IDS_QUERY = """
154
+ query FindTagsByIds($ids: [ID!]) {
155
+ findTags(ids: $ids, filter: {per_page: -1}) {
156
+ tags {
157
+ id
158
+ name
159
+ sort_name
160
+ aliases
161
+ description
162
+ favorite
163
+ ignore_auto_tag
164
+ image_path
165
+ custom_fields
166
+ scene_count
167
+ scene_marker_count
168
+ image_count
169
+ gallery_count
170
+ performer_count
171
+ studio_count
172
+ group_count
173
+ parents { id name }
174
+ children { id name }
175
+ }
176
+ }
177
+ }
178
+ """
179
+
180
+ _TAG_NOT_FOUND_ID = re.compile(r"\btag with id (\d+) not found\b")
181
+
182
+
183
+ def _tag_not_found(missing_ids: list[str]) -> core.CliError:
184
+ return core.CliError(
185
+ f"Tag not found: {', '.join(missing_ids)}.",
186
+ 2,
187
+ "tag_not_found",
188
+ {"missing_ids": missing_ids},
189
+ )
190
+
191
+
192
+ def find_tags_by_ids(ids: list[str]) -> list[dict]:
193
+ """The tags with `ids`, in the order Stash returns them.
194
+
195
+ Stash fails the whole read at the first missing ID; that ID, or any
196
+ requested ID absent from the result, raises `tag_not_found`.
197
+ """
198
+ try:
199
+ data = core.call(_FIND_TAGS_BY_IDS_QUERY, {"ids": ids})
200
+ except core.CliError as error:
201
+ match = _TAG_NOT_FOUND_ID.search(str(error))
202
+ if error.code == "graphql_error" and match:
203
+ raise _tag_not_found([match.group(1)]) from error
204
+ raise
205
+ tags = data.get("findTags", {}).get("tags", [])
206
+ found = {tag["id"] for tag in tags}
207
+ missing = [tag_id for tag_id in ids if tag_id not in found]
208
+ if missing:
209
+ raise _tag_not_found(missing)
210
+ return tags
211
+
212
+
213
+ _DESTROY_TAGS_MUTATION = """
214
+ mutation DestroyTags($ids: [ID!]!) {
215
+ tagsDestroy(ids: $ids)
216
+ }
217
+ """
218
+
219
+
220
+ def destroy_tags(ids: list[str]) -> None:
221
+ """Delete the tags in one all-or-nothing `tagsDestroy`."""
222
+ data = core.call(_DESTROY_TAGS_MUTATION, {"ids": ids})
223
+ if data.get("tagsDestroy") is not True:
224
+ raise core.CliError(
225
+ f"Stash did not delete the tags: tagsDestroy returned "
226
+ f"{data.get('tagsDestroy')!r}.",
227
+ 1,
228
+ "graphql_error",
229
+ )
230
+
231
+
232
+ _MERGE_TAGS_MUTATION = """
233
+ mutation MergeTags($input: TagsMergeInput!) {
234
+ tagsMerge(input: $input) {
235
+ id
236
+ }
237
+ }
238
+ """
239
+
240
+
241
+ def merge_tags(input_data: dict) -> str:
242
+ """Merge the source tags into the destination and return its ID."""
243
+ data = core.call(_MERGE_TAGS_MUTATION, {"input": input_data})
244
+ return data["tagsMerge"]["id"]
@@ -0,0 +1,116 @@
1
+ """Resolve stash-box and scrape-source values against the Stash configuration.
2
+
3
+ Commands call these before building a dry-run report, so an invalid value
4
+ fails the same way with or without `--apply`. Every failure exits 2.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import core, queries
10
+
11
+ # Source prefixes that choose a scraper or a stash-box when a value is both.
12
+ SCRAPER_PREFIX = "scraper"
13
+ STASHBOX_PREFIX = "stashbox"
14
+
15
+
16
+ def _box_label(box: dict) -> str:
17
+ return f"{box['name']} ({box['endpoint']})"
18
+
19
+
20
+ def _matching_boxes(value: str, boxes: list[dict]) -> list[dict]:
21
+ return [box for box in boxes if value in (box["endpoint"], box["name"])]
22
+
23
+
24
+ def list_stash_boxes() -> list[dict]:
25
+ """Configured stash-boxes as `{"endpoint", "name"}`; empty if none."""
26
+ return queries.sources.list_stash_boxes()
27
+
28
+
29
+ def resolve_stash_box(value: str, boxes: list[dict] | None = None) -> dict:
30
+ """Return the configured stash-box whose endpoint or name is `value`."""
31
+ if boxes is None:
32
+ boxes = list_stash_boxes()
33
+ if not boxes:
34
+ raise core.CliError(
35
+ "No stash-box is configured in Stash. Add one in Stash under "
36
+ "Settings > Metadata Providers.",
37
+ 2,
38
+ "no_stash_box_configured",
39
+ )
40
+ matches = _matching_boxes(value, boxes)
41
+ if len(matches) != 1:
42
+ raise core.CliError(
43
+ f"{value!r} is not a configured stash-box. Use a name or endpoint: "
44
+ + ", ".join(_box_label(box) for box in boxes)
45
+ + ".",
46
+ 2,
47
+ "unknown_stash_box_endpoint",
48
+ details={"stash_boxes": boxes},
49
+ )
50
+ return matches[0]
51
+
52
+
53
+ def resolve_source(value: str, content_type: str) -> dict:
54
+ """Resolve `value` to a Stash `ScraperSourceInput` for `content_type`.
55
+
56
+ A configured stash-box endpoint or name gives `stash_box_endpoint`; the id
57
+ of an installed scraper for `content_type` gives `scraper_id`. A
58
+ `scraper:` or `stashbox:` prefix chooses one kind and never matches the
59
+ other.
60
+ """
61
+ boxes, scraper_ids = queries.sources.list_sources(content_type)
62
+ prefix, _, rest = value.partition(":")
63
+ if prefix == STASHBOX_PREFIX:
64
+ return {"stash_box_endpoint": resolve_stash_box(rest, boxes)["endpoint"]}
65
+ if prefix == SCRAPER_PREFIX:
66
+ if rest in scraper_ids:
67
+ return {"scraper_id": rest}
68
+ raise _unknown_source(value, content_type, boxes)
69
+
70
+ box_matches = _matching_boxes(value, boxes)
71
+ is_scraper = value in scraper_ids
72
+ if len(box_matches) + is_scraper > 1:
73
+ matches: dict = {"stash_boxes": box_matches}
74
+ if is_scraper:
75
+ matches["scraper_id"] = value
76
+ # `stashbox:VALUE` is only unique when one stash-box matches; otherwise
77
+ # each stash-box is named by its endpoint.
78
+ box_forms = (
79
+ [f"{STASHBOX_PREFIX}:{value}"]
80
+ if len(box_matches) == 1
81
+ else [f"{STASHBOX_PREFIX}:{box['endpoint']}" for box in box_matches]
82
+ )
83
+ forms = ([f"{SCRAPER_PREFIX}:{value}"] if is_scraper else []) + box_forms
84
+ raise core.CliError(
85
+ f"{value!r} matches more than one source: "
86
+ + ", ".join(
87
+ [f"stash-box {_box_label(box)}" for box in box_matches]
88
+ + ([f"scraper {value}"] if is_scraper else [])
89
+ )
90
+ + ". Pass one of: "
91
+ + ", ".join(forms)
92
+ + ".",
93
+ 2,
94
+ "ambiguous_source",
95
+ details={"matches": matches},
96
+ )
97
+ if box_matches:
98
+ return {"stash_box_endpoint": box_matches[0]["endpoint"]}
99
+ if is_scraper:
100
+ return {"scraper_id": value}
101
+ raise _unknown_source(value, content_type, boxes)
102
+
103
+
104
+ def _unknown_source(value: str, content_type: str, boxes: list[dict]) -> core.CliError:
105
+ boxes_text = (
106
+ "configured stash-boxes: " + ", ".join(_box_label(box) for box in boxes)
107
+ if boxes
108
+ else "no stash-boxes are configured"
109
+ )
110
+ return core.CliError(
111
+ f"{value!r} is not a configured stash-box or an installed "
112
+ f"{content_type.lower()} scraper ({boxes_text}). Run "
113
+ "`stashapp-cli scrape sources` to list the valid sources.",
114
+ 2,
115
+ "unknown_source",
116
+ )