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,173 @@
|
|
|
1
|
+
"""stashbox commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import core, queries, sources
|
|
8
|
+
from ._common import CommandKind, add_leaf
|
|
9
|
+
|
|
10
|
+
OPERATION = "submitStashBoxFingerprints"
|
|
11
|
+
|
|
12
|
+
# Skip reasons, checked in this order.
|
|
13
|
+
NO_STASH_ID_FOR_ENDPOINT = "no_stash_id_for_endpoint"
|
|
14
|
+
NO_FILE_DURATION = "no_file_duration"
|
|
15
|
+
NO_FINGERPRINTS = "no_fingerprints"
|
|
16
|
+
|
|
17
|
+
# The fingerprint types that Stash sends, in report order.
|
|
18
|
+
SUBMITTED_TYPES = ("md5", "oshash", "phash")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plan_submission(scenes: list[dict], endpoint: str) -> tuple[list[dict], list[dict]]:
|
|
22
|
+
"""Split `scenes` into the ones Stash will submit and the ones it skips.
|
|
23
|
+
|
|
24
|
+
Mirrors `SubmitFingerprints` in Stash's `pkg/stashbox/scene.go`: a scene
|
|
25
|
+
needs a stash ID whose endpoint equals `endpoint` exactly, only files with
|
|
26
|
+
a duration above 0 are sent, and only MD5, OSHASH, and PHASH fingerprints
|
|
27
|
+
are sent. Recheck that function when Stash changes it.
|
|
28
|
+
"""
|
|
29
|
+
submit: list[dict] = []
|
|
30
|
+
skip: list[dict] = []
|
|
31
|
+
for scene in scenes:
|
|
32
|
+
scene_id = str(scene["id"])
|
|
33
|
+
stash_ids = scene.get("stash_ids") or []
|
|
34
|
+
if not any(stash_id.get("endpoint") == endpoint for stash_id in stash_ids):
|
|
35
|
+
skip.append({"scene_id": scene_id, "reason": NO_STASH_ID_FOR_ENDPOINT})
|
|
36
|
+
continue
|
|
37
|
+
files = [
|
|
38
|
+
file for file in scene.get("files") or [] if (file.get("duration") or 0) > 0
|
|
39
|
+
]
|
|
40
|
+
if not files:
|
|
41
|
+
skip.append({"scene_id": scene_id, "reason": NO_FILE_DURATION})
|
|
42
|
+
continue
|
|
43
|
+
found = {
|
|
44
|
+
str(fingerprint.get("type", "")).lower()
|
|
45
|
+
for file in files
|
|
46
|
+
for fingerprint in file.get("fingerprints") or []
|
|
47
|
+
}
|
|
48
|
+
fingerprints = [kind for kind in SUBMITTED_TYPES if kind in found]
|
|
49
|
+
if not fingerprints:
|
|
50
|
+
skip.append({"scene_id": scene_id, "reason": NO_FINGERPRINTS})
|
|
51
|
+
continue
|
|
52
|
+
submit.append(
|
|
53
|
+
{
|
|
54
|
+
"scene_id": scene_id,
|
|
55
|
+
"fingerprints": fingerprints,
|
|
56
|
+
"missing": [] if "phash" in fingerprints else ["phash"],
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
return submit, skip
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _scene_ids(values: list[str]) -> list[str]:
|
|
63
|
+
for value in values:
|
|
64
|
+
if not (value.isascii() and value.isdigit()) or int(value) <= 0:
|
|
65
|
+
raise core.CliError(
|
|
66
|
+
f"--scene-id must be a positive integer, got {value!r}.",
|
|
67
|
+
2,
|
|
68
|
+
"usage_error",
|
|
69
|
+
)
|
|
70
|
+
return list(dict.fromkeys(values))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _submission_failed(endpoint: str, scene_ids: list[str], cause: str, reason: str):
|
|
74
|
+
return core.CliError(
|
|
75
|
+
f"Fingerprint submission to {endpoint} failed: {reason}. Some "
|
|
76
|
+
"fingerprints may already have been sent; Stash does not report which.",
|
|
77
|
+
1,
|
|
78
|
+
"fingerprint_submission_failed",
|
|
79
|
+
details={"endpoint": endpoint, "scene_ids": scene_ids, "cause": cause},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_stashbox_submit_fingerprints(args: argparse.Namespace) -> object:
|
|
84
|
+
scene_ids = _scene_ids(args.scene_id)
|
|
85
|
+
endpoint = sources.resolve_stash_box(args.endpoint)["endpoint"]
|
|
86
|
+
scenes = queries.stashbox.get_scene_fingerprints(scene_ids)
|
|
87
|
+
by_id = {str(scene["id"]): scene for scene in scenes}
|
|
88
|
+
ordered = [by_id[scene_id] for scene_id in scene_ids]
|
|
89
|
+
submit, skip = plan_submission(ordered, endpoint)
|
|
90
|
+
input_data = {
|
|
91
|
+
"scene_ids": [entry["scene_id"] for entry in submit],
|
|
92
|
+
"stash_box_endpoint": endpoint,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
applied = False
|
|
96
|
+
if args.apply and submit:
|
|
97
|
+
try:
|
|
98
|
+
result = queries.stashbox.submit_fingerprints(input_data)
|
|
99
|
+
except core.CliError as error:
|
|
100
|
+
if error.exit_code != 1:
|
|
101
|
+
raise
|
|
102
|
+
raise _submission_failed(
|
|
103
|
+
endpoint, input_data["scene_ids"], error.code, str(error)
|
|
104
|
+
) from error
|
|
105
|
+
if result is not True:
|
|
106
|
+
raise _submission_failed(
|
|
107
|
+
endpoint,
|
|
108
|
+
input_data["scene_ids"],
|
|
109
|
+
"false_result",
|
|
110
|
+
"Stash returned false",
|
|
111
|
+
)
|
|
112
|
+
applied = True
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"applied": applied,
|
|
116
|
+
"operation": OPERATION,
|
|
117
|
+
"endpoint": endpoint,
|
|
118
|
+
"input": input_data,
|
|
119
|
+
"submit": submit,
|
|
120
|
+
"skip": skip,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
125
|
+
stashbox = subparsers.add_parser(
|
|
126
|
+
"stashbox", help="Send data to a configured stash-box through Stash."
|
|
127
|
+
)
|
|
128
|
+
stashbox_sub = stashbox.add_subparsers(dest="stashbox_command", required=True)
|
|
129
|
+
|
|
130
|
+
submit = add_leaf(
|
|
131
|
+
stashbox_sub,
|
|
132
|
+
"submit-fingerprints",
|
|
133
|
+
kind=CommandKind.WRITE,
|
|
134
|
+
func=cmd_stashbox_submit_fingerprints,
|
|
135
|
+
help="Submit the fingerprints of matched scenes to a stash-box.",
|
|
136
|
+
examples=(
|
|
137
|
+
(
|
|
138
|
+
"stashapp-cli stashbox submit-fingerprints --endpoint ExampleBox "
|
|
139
|
+
"--scene-id 812"
|
|
140
|
+
),
|
|
141
|
+
(
|
|
142
|
+
"stashapp-cli stashbox submit-fingerprints --endpoint "
|
|
143
|
+
"https://box.example/graphql --scene-id 812 --scene-id 813 --apply"
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
submit.description = (
|
|
148
|
+
"Report which scenes Stash would submit to the stash-box and which it\n"
|
|
149
|
+
"would skip, then, with --apply, submit only the eligible scenes.\n"
|
|
150
|
+
"\n"
|
|
151
|
+
"A scene needs a stash ID for the endpoint before it can be submitted;\n"
|
|
152
|
+
"add one with `scene update --add-stash-id`. A scene without a phash is\n"
|
|
153
|
+
"still submitted and lists phash in `missing`; `generate run --phashes`\n"
|
|
154
|
+
"adds it.\n"
|
|
155
|
+
"\n"
|
|
156
|
+
"Skip reasons:\n"
|
|
157
|
+
f" {NO_STASH_ID_FOR_ENDPOINT} the scene has no stash ID for the endpoint\n"
|
|
158
|
+
f" {NO_FILE_DURATION} no file has a duration above 0\n"
|
|
159
|
+
f" {NO_FINGERPRINTS} no such file has an md5, oshash, or phash"
|
|
160
|
+
)
|
|
161
|
+
submit.add_argument(
|
|
162
|
+
"--scene-id",
|
|
163
|
+
action="append",
|
|
164
|
+
required=True,
|
|
165
|
+
metavar="ID",
|
|
166
|
+
help="Scene to submit; repeat for more scenes.",
|
|
167
|
+
)
|
|
168
|
+
submit.add_argument(
|
|
169
|
+
"--endpoint",
|
|
170
|
+
required=True,
|
|
171
|
+
metavar="VALUE",
|
|
172
|
+
help="Configured stash-box endpoint URL or name (always required).",
|
|
173
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""studio commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .. import core, queries
|
|
8
|
+
from ._common import (
|
|
9
|
+
STUDIO_ALIASES,
|
|
10
|
+
TAG_IDS,
|
|
11
|
+
URLS,
|
|
12
|
+
CommandKind,
|
|
13
|
+
FindSpec,
|
|
14
|
+
add_find_options,
|
|
15
|
+
add_get_command,
|
|
16
|
+
add_leaf,
|
|
17
|
+
add_list_options,
|
|
18
|
+
add_update_target,
|
|
19
|
+
bulk_update_input,
|
|
20
|
+
find_filter,
|
|
21
|
+
mutation_result,
|
|
22
|
+
read_json_arg,
|
|
23
|
+
require_list_target,
|
|
24
|
+
resolve_update_input,
|
|
25
|
+
unique,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
FIND = FindSpec("studios", "name", match_aliases=True, sort="name")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_studio_find(args: argparse.Namespace) -> object:
|
|
32
|
+
return queries.studio.find_studios(
|
|
33
|
+
find_filter(args, FIND), args.page, args.per_page
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_studio_create(args: argparse.Namespace) -> object:
|
|
38
|
+
input_data = read_json_arg(args.input) if args.input is not None else {}
|
|
39
|
+
if not isinstance(input_data, dict):
|
|
40
|
+
raise core.CliError(
|
|
41
|
+
"--input must be a StudioCreateInput JSON object.", 2, "usage_error"
|
|
42
|
+
)
|
|
43
|
+
if args.name is not None:
|
|
44
|
+
input_data["name"] = args.name
|
|
45
|
+
if args.url is not None:
|
|
46
|
+
input_data["urls"] = args.url
|
|
47
|
+
if args.parent_id is not None:
|
|
48
|
+
input_data["parent_id"] = args.parent_id
|
|
49
|
+
if not input_data.get("name"):
|
|
50
|
+
raise core.CliError(
|
|
51
|
+
"Pass a studio NAME or a name in --input.", 2, "usage_error"
|
|
52
|
+
)
|
|
53
|
+
return mutation_result(
|
|
54
|
+
"studioCreate",
|
|
55
|
+
args.apply,
|
|
56
|
+
input_data,
|
|
57
|
+
lambda: queries.studio.create_studio(input_data),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
LIST_FIELDS = (TAG_IDS, URLS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _usage_error(message: str) -> core.CliError:
|
|
65
|
+
return core.CliError(message, 2, "usage_error")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def apply_alias_changes(
|
|
69
|
+
current: list[str], add: list[str], remove: list[str]
|
|
70
|
+
) -> list[str]:
|
|
71
|
+
"""The alias list after removing and then adding values.
|
|
72
|
+
|
|
73
|
+
Kept aliases keep their order, and new ones follow in the order first given.
|
|
74
|
+
Values are compared exactly.
|
|
75
|
+
"""
|
|
76
|
+
aliases = [alias for alias in current if alias not in remove]
|
|
77
|
+
for alias in unique(add):
|
|
78
|
+
if alias not in aliases:
|
|
79
|
+
aliases.append(alias)
|
|
80
|
+
return aliases
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _update_studio_aliases(args: argparse.Namespace, fields: dict) -> object:
|
|
84
|
+
"""Read the studio, change its aliases, and send one studioUpdate."""
|
|
85
|
+
add, remove = args.add_alias or [], args.remove_alias or []
|
|
86
|
+
if "" in add + remove:
|
|
87
|
+
raise _usage_error("--add-alias and --remove-alias need a non-empty value.")
|
|
88
|
+
current = queries.studio.get_studio_aliases(args.id)
|
|
89
|
+
if current is None:
|
|
90
|
+
raise core.not_found_error("studio", args.id)
|
|
91
|
+
input_data = {
|
|
92
|
+
"id": args.id,
|
|
93
|
+
**fields,
|
|
94
|
+
"aliases": apply_alias_changes(current, add, remove),
|
|
95
|
+
}
|
|
96
|
+
return mutation_result(
|
|
97
|
+
"studioUpdate",
|
|
98
|
+
args.apply,
|
|
99
|
+
input_data,
|
|
100
|
+
lambda: queries.studio.update_studio(input_data),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_studio_update(args: argparse.Namespace) -> object:
|
|
105
|
+
fields = {
|
|
106
|
+
field: value
|
|
107
|
+
for field, value in (
|
|
108
|
+
("name", args.name),
|
|
109
|
+
("parent_id", args.parent_id),
|
|
110
|
+
("details", args.details),
|
|
111
|
+
)
|
|
112
|
+
if value is not None
|
|
113
|
+
}
|
|
114
|
+
bulk_input = bulk_update_input(args, LIST_FIELDS)
|
|
115
|
+
alias = bool(args.add_alias or args.remove_alias)
|
|
116
|
+
if bulk_input is not None or alias:
|
|
117
|
+
require_list_target(args)
|
|
118
|
+
if bulk_input is not None and (fields or alias):
|
|
119
|
+
raise _usage_error(
|
|
120
|
+
"The tag and URL options cannot be combined with --name, "
|
|
121
|
+
"--parent-id, --details, or the alias options; run two commands."
|
|
122
|
+
)
|
|
123
|
+
if bulk_input is not None:
|
|
124
|
+
return mutation_result(
|
|
125
|
+
"bulkStudioUpdate",
|
|
126
|
+
args.apply,
|
|
127
|
+
bulk_input,
|
|
128
|
+
lambda: queries.studio.bulk_update_studio(bulk_input),
|
|
129
|
+
)
|
|
130
|
+
if alias:
|
|
131
|
+
return _update_studio_aliases(args, fields)
|
|
132
|
+
input_data = resolve_update_input(args, fields)
|
|
133
|
+
return mutation_result(
|
|
134
|
+
"studioUpdate",
|
|
135
|
+
args.apply,
|
|
136
|
+
input_data,
|
|
137
|
+
lambda: queries.studio.update_studio(input_data),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def register(subparsers: argparse._SubParsersAction) -> None:
|
|
142
|
+
studio = subparsers.add_parser("studio", help="Studio lookups and writes.")
|
|
143
|
+
studio_sub = studio.add_subparsers(dest="studio_command", required=True)
|
|
144
|
+
|
|
145
|
+
add_get_command(studio_sub, "studio", queries.studio.get_studio, example_id=13)
|
|
146
|
+
|
|
147
|
+
find = add_leaf(
|
|
148
|
+
studio_sub,
|
|
149
|
+
"find",
|
|
150
|
+
kind=CommandKind.READ,
|
|
151
|
+
func=cmd_studio_find,
|
|
152
|
+
help="Find studios by exact name or alias, or by a StudioFilterType.",
|
|
153
|
+
examples=(
|
|
154
|
+
"stashapp-cli studio find --name ExampleStudio",
|
|
155
|
+
"stashapp-cli studio find --name 'example studio online' | jq -r '.studios[0].id'",
|
|
156
|
+
"stashapp-cli studio find --filter-json '{\"favorite\": true}' --per-page 100",
|
|
157
|
+
"stashapp-cli studio find --name ExampleStudio --filter-json @studio-filter.json",
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
add_find_options(find, FIND)
|
|
161
|
+
|
|
162
|
+
create = add_leaf(
|
|
163
|
+
studio_sub,
|
|
164
|
+
"create",
|
|
165
|
+
kind=CommandKind.WRITE,
|
|
166
|
+
func=cmd_studio_create,
|
|
167
|
+
help="Create a studio (dry-run by default; see global --apply).",
|
|
168
|
+
examples=(
|
|
169
|
+
"stashapp-cli studio create ExampleStudio --url https://example-studio.com",
|
|
170
|
+
"stashapp-cli studio create 'ExampleStudio Online' --parent-id 4",
|
|
171
|
+
"stashapp-cli studio create --input @studio.json",
|
|
172
|
+
"stashapp-cli studio create ExampleStudio --input @studio.json --apply",
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
create.add_argument(
|
|
176
|
+
"name",
|
|
177
|
+
nargs="?",
|
|
178
|
+
default=None,
|
|
179
|
+
metavar="NAME",
|
|
180
|
+
help="Studio name; overrides name in --input.",
|
|
181
|
+
)
|
|
182
|
+
create.add_argument(
|
|
183
|
+
"--url",
|
|
184
|
+
action="append",
|
|
185
|
+
default=None,
|
|
186
|
+
help="May be given more than once; replaces urls in --input.",
|
|
187
|
+
)
|
|
188
|
+
create.add_argument(
|
|
189
|
+
"--parent-id",
|
|
190
|
+
default=None,
|
|
191
|
+
metavar="ID",
|
|
192
|
+
help="Parent studio; overrides parent_id.",
|
|
193
|
+
)
|
|
194
|
+
create.add_argument(
|
|
195
|
+
"--input",
|
|
196
|
+
default=None,
|
|
197
|
+
help="StudioCreateInput as JSON, @file, or @- (options override its fields).",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
update = add_leaf(
|
|
201
|
+
studio_sub,
|
|
202
|
+
"update",
|
|
203
|
+
kind=CommandKind.WRITE,
|
|
204
|
+
func=cmd_studio_update,
|
|
205
|
+
help="Update a studio (dry-run by default; see global --apply).",
|
|
206
|
+
examples=(
|
|
207
|
+
"stashapp-cli studio update 13 --name 'Example Studio Online'",
|
|
208
|
+
"stashapp-cli studio update 13 --parent-id 4 --details 'Film studio' --apply",
|
|
209
|
+
"stashapp-cli studio update 13 --add-alias 'Example Studio'",
|
|
210
|
+
"stashapp-cli studio update 13 --add-tag-id 55 --remove-url https://old.example --apply",
|
|
211
|
+
"stashapp-cli studio update 13 --input @studio.json --apply",
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
add_update_target(update, "StudioUpdateInput")
|
|
215
|
+
update.add_argument(
|
|
216
|
+
"--name", default=None, help="Studio name; overrides name in --input."
|
|
217
|
+
)
|
|
218
|
+
update.add_argument(
|
|
219
|
+
"--parent-id",
|
|
220
|
+
default=None,
|
|
221
|
+
metavar="ID",
|
|
222
|
+
help="Parent studio; overrides parent_id in --input.",
|
|
223
|
+
)
|
|
224
|
+
update.add_argument(
|
|
225
|
+
"--details", default=None, help="Description; overrides details in --input."
|
|
226
|
+
)
|
|
227
|
+
add_list_options(update, (TAG_IDS, URLS, STUDIO_ALIASES))
|