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
|
@@ -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"]
|
stashapp_cli/sources.py
ADDED
|
@@ -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
|
+
)
|