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,20 @@
|
|
|
1
|
+
"""Database backup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import core
|
|
6
|
+
|
|
7
|
+
_BACKUP_DATABASE_MUTATION = """
|
|
8
|
+
mutation BackupDatabase($input: BackupDatabaseInput!) {
|
|
9
|
+
backupDatabase(input: $input)
|
|
10
|
+
}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def backup_database(download: bool) -> str | None:
|
|
15
|
+
"""Back up the database; returns the download URL, or None without download.
|
|
16
|
+
|
|
17
|
+
Stash runs the backup before it answers; this is not a job.
|
|
18
|
+
"""
|
|
19
|
+
data = core.call(_BACKUP_DATABASE_MUTATION, {"input": {"download": download}})
|
|
20
|
+
return data.get("backupDatabase")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Group lookups and writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import core
|
|
6
|
+
from ._get import ObjectSpec, get_object
|
|
7
|
+
from ._image_flags import add_page_image_flags
|
|
8
|
+
|
|
9
|
+
_IMAGE_FLAGS = {
|
|
10
|
+
"front_image_path": "has_front_image",
|
|
11
|
+
"back_image_path": "has_back_image",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
_GROUP_FIELDS = """
|
|
15
|
+
id
|
|
16
|
+
name
|
|
17
|
+
aliases
|
|
18
|
+
synopsis
|
|
19
|
+
date
|
|
20
|
+
director
|
|
21
|
+
front_image_path
|
|
22
|
+
back_image_path
|
|
23
|
+
urls
|
|
24
|
+
custom_fields
|
|
25
|
+
studio { id }
|
|
26
|
+
tags { id }
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
GROUP = ObjectSpec("findGroup", _GROUP_FIELDS, _IMAGE_FLAGS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_group(group_id: str) -> dict | None:
|
|
33
|
+
"""One group by id, in the shape find_groups returns, or `None`."""
|
|
34
|
+
return get_object(GROUP, group_id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_FIND_GROUPS_QUERY = f"""
|
|
38
|
+
query FindGroups($group_filter: GroupFilterType!, $page: Int!, $per_page: Int!) {{
|
|
39
|
+
findGroups(
|
|
40
|
+
group_filter: $group_filter
|
|
41
|
+
filter: {{page: $page, per_page: $per_page, sort: "name", direction: ASC}}
|
|
42
|
+
) {{
|
|
43
|
+
count
|
|
44
|
+
groups {{
|
|
45
|
+
{_GROUP_FIELDS}
|
|
46
|
+
}}
|
|
47
|
+
}}
|
|
48
|
+
}}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_groups(group_filter: dict, page: int, per_page: int = 50) -> dict:
|
|
53
|
+
"""One page of the groups matching a Stash group filter.
|
|
54
|
+
|
|
55
|
+
Each group also gets `has_front_image` and `has_back_image` flags; see
|
|
56
|
+
`_image_flags` for the rule.
|
|
57
|
+
"""
|
|
58
|
+
result = core.call(
|
|
59
|
+
_FIND_GROUPS_QUERY,
|
|
60
|
+
{"group_filter": group_filter, "page": page, "per_page": per_page},
|
|
61
|
+
)["findGroups"]
|
|
62
|
+
return add_page_image_flags(result, "groups", _IMAGE_FLAGS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_CREATE_GROUP_MUTATION = """
|
|
66
|
+
mutation CreateGroup($input: GroupCreateInput!) {
|
|
67
|
+
groupCreate(input: $input) {
|
|
68
|
+
id
|
|
69
|
+
name
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_group(input_data: dict) -> str:
|
|
76
|
+
data = core.call(_CREATE_GROUP_MUTATION, {"input": input_data})
|
|
77
|
+
return data["groupCreate"]["id"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_UPDATE_GROUP_MUTATION = """
|
|
81
|
+
mutation UpdateGroup($input: GroupUpdateInput!) {
|
|
82
|
+
groupUpdate(input: $input) {
|
|
83
|
+
id
|
|
84
|
+
name
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def update_group(input_data: dict) -> str:
|
|
91
|
+
data = core.call(_UPDATE_GROUP_MUTATION, {"input": input_data})
|
|
92
|
+
return data["groupUpdate"]["id"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_BULK_UPDATE_GROUP_MUTATION = """
|
|
96
|
+
mutation BulkUpdateGroup($input: BulkGroupUpdateInput!) {
|
|
97
|
+
bulkGroupUpdate(input: $input) {
|
|
98
|
+
id
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# Stash's whole error message for a bulkGroupUpdate of a missing group. It is
|
|
104
|
+
# generic, so it is matched exactly rather than as a substring.
|
|
105
|
+
_BULK_GROUP_NOT_FOUND = f"{core.GRAPHQL_ERROR_PREFIX}not found"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def bulk_update_group(input_data: dict) -> str:
|
|
109
|
+
"""Send a one-group bulkGroupUpdate and return the group id."""
|
|
110
|
+
(group_id,) = input_data["ids"]
|
|
111
|
+
try:
|
|
112
|
+
data = core.call(_BULK_UPDATE_GROUP_MUTATION, {"input": input_data})
|
|
113
|
+
except core.CliError as error:
|
|
114
|
+
if error.code == "graphql_error" and str(error) == _BULK_GROUP_NOT_FOUND:
|
|
115
|
+
raise core.not_found_error("group", group_id) from error
|
|
116
|
+
raise
|
|
117
|
+
return core.returned_id("bulkGroupUpdate", data, group_id)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Background job state.
|
|
2
|
+
|
|
3
|
+
Stash keeps only its most recent finished jobs, so a finished job can
|
|
4
|
+
disappear. `subTasks` is not selected, to keep the result bounded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .. import core
|
|
10
|
+
|
|
11
|
+
_FIND_JOB_QUERY = """
|
|
12
|
+
query FindJob($input: FindJobInput!) {
|
|
13
|
+
findJob(input: $input) {
|
|
14
|
+
id
|
|
15
|
+
status
|
|
16
|
+
progress
|
|
17
|
+
description
|
|
18
|
+
error
|
|
19
|
+
addTime
|
|
20
|
+
startTime
|
|
21
|
+
endTime
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_job(job_id: str) -> dict | None:
|
|
28
|
+
"""Return the job with `job_id`, or None when Stash does not know it."""
|
|
29
|
+
return core.call(_FIND_JOB_QUERY, {"input": {"id": job_id}}).get("findJob")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Background metadata jobs: scan, generate, and identify.
|
|
2
|
+
|
|
3
|
+
Each mutation starts a job in Stash and returns its id at once. Use
|
|
4
|
+
`queries.job.find_job` to read the job's state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .. import core
|
|
10
|
+
|
|
11
|
+
_SCAN_MUTATION = """
|
|
12
|
+
mutation MetadataScan($input: ScanMetadataInput!) {
|
|
13
|
+
metadataScan(input: $input)
|
|
14
|
+
}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_GENERATE_MUTATION = """
|
|
18
|
+
mutation MetadataGenerate($input: GenerateMetadataInput!) {
|
|
19
|
+
metadataGenerate(input: $input)
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_IDENTIFY_MUTATION = """
|
|
24
|
+
mutation MetadataIdentify($input: IdentifyMetadataInput!) {
|
|
25
|
+
metadataIdentify(input: $input)
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def start_scan(input_data: dict) -> str:
|
|
31
|
+
"""Start a scan job from a `ScanMetadataInput` and return the job id."""
|
|
32
|
+
return core.call(_SCAN_MUTATION, {"input": input_data})["metadataScan"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def start_generate(input_data: dict) -> str:
|
|
36
|
+
"""Start a generate job from a `GenerateMetadataInput` and return the job id."""
|
|
37
|
+
return core.call(_GENERATE_MUTATION, {"input": input_data})["metadataGenerate"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def start_identify(input_data: dict) -> str:
|
|
41
|
+
"""Start an identify job from an `IdentifyMetadataInput` and return the job id."""
|
|
42
|
+
return core.call(_IDENTIFY_MUTATION, {"input": input_data})["metadataIdentify"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Performer 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
|
+
_PERFORMER_FIELDS = """
|
|
14
|
+
id
|
|
15
|
+
name
|
|
16
|
+
disambiguation
|
|
17
|
+
alias_list
|
|
18
|
+
details
|
|
19
|
+
urls
|
|
20
|
+
image_path
|
|
21
|
+
custom_fields
|
|
22
|
+
stash_ids { endpoint stash_id }
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
PERFORMER = ObjectSpec("findPerformer", _PERFORMER_FIELDS, _IMAGE_FLAGS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_performer(performer_id: str) -> dict | None:
|
|
29
|
+
"""One performer by id, in the shape find_performers returns, or `None`."""
|
|
30
|
+
return get_object(PERFORMER, performer_id)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_FIND_PERFORMERS_QUERY = f"""
|
|
34
|
+
query FindPerformers(
|
|
35
|
+
$performer_filter: PerformerFilterType!
|
|
36
|
+
$page: Int!
|
|
37
|
+
$per_page: Int!
|
|
38
|
+
) {{
|
|
39
|
+
findPerformers(
|
|
40
|
+
performer_filter: $performer_filter
|
|
41
|
+
filter: {{page: $page, per_page: $per_page, sort: "name", direction: ASC}}
|
|
42
|
+
) {{
|
|
43
|
+
count
|
|
44
|
+
performers {{
|
|
45
|
+
{_PERFORMER_FIELDS}
|
|
46
|
+
}}
|
|
47
|
+
}}
|
|
48
|
+
}}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_performers(performer_filter: dict, page: int, per_page: int = 50) -> dict:
|
|
53
|
+
"""One page of the performers matching a Stash performer filter.
|
|
54
|
+
|
|
55
|
+
Each performer also gets a `has_image` flag; see `_image_flags` for the rule.
|
|
56
|
+
"""
|
|
57
|
+
result = core.call(
|
|
58
|
+
_FIND_PERFORMERS_QUERY,
|
|
59
|
+
{"performer_filter": performer_filter, "page": page, "per_page": per_page},
|
|
60
|
+
)["findPerformers"]
|
|
61
|
+
return add_page_image_flags(result, "performers", _IMAGE_FLAGS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_CREATE_PERFORMER_MUTATION = """
|
|
65
|
+
mutation CreatePerformer($name: String!, $gender: GenderEnum, $urls: [String!]) {
|
|
66
|
+
performerCreate(input: {name: $name, gender: $gender, urls: $urls}) {
|
|
67
|
+
id
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_performer(
|
|
74
|
+
name: str, gender: str | None = None, urls: list[str] | None = None
|
|
75
|
+
) -> str:
|
|
76
|
+
data = core.call(
|
|
77
|
+
_CREATE_PERFORMER_MUTATION,
|
|
78
|
+
{"name": name, "gender": gender, "urls": urls or []},
|
|
79
|
+
)
|
|
80
|
+
return data["performerCreate"]["id"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_UPDATE_PERFORMER_MUTATION = """
|
|
84
|
+
mutation UpdatePerformer($input: PerformerUpdateInput!) {
|
|
85
|
+
performerUpdate(input: $input) {
|
|
86
|
+
id
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def update_performer(input_data: dict) -> str:
|
|
93
|
+
data = core.call(_UPDATE_PERFORMER_MUTATION, {"input": input_data})
|
|
94
|
+
return data["performerUpdate"]["id"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Selects only what the merge loss report reads; never image fields.
|
|
98
|
+
_FIND_PERFORMERS_BY_IDS_QUERY = """
|
|
99
|
+
query FindPerformersByIds($ids: [ID!]) {
|
|
100
|
+
findPerformers(ids: $ids, filter: {per_page: -1}) {
|
|
101
|
+
performers {
|
|
102
|
+
id
|
|
103
|
+
name
|
|
104
|
+
alias_list
|
|
105
|
+
urls
|
|
106
|
+
stash_ids { endpoint stash_id }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_PERFORMER_NOT_FOUND = re.compile(r"\bperformer with id (\d+) not found\b")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _performer_not_found(missing_ids: list[str]) -> core.CliError:
|
|
116
|
+
return core.CliError(
|
|
117
|
+
f"Performer not found: {', '.join(missing_ids)}.",
|
|
118
|
+
2,
|
|
119
|
+
"performer_not_found",
|
|
120
|
+
{"missing_ids": missing_ids},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def find_performers_by_ids(ids: list[str]) -> list[dict]:
|
|
125
|
+
"""The performers with `ids`, in the order Stash returns them.
|
|
126
|
+
|
|
127
|
+
Stash fails the whole read at the first missing ID; that ID, or any
|
|
128
|
+
requested ID absent from the result, raises `performer_not_found`.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
data = core.call(_FIND_PERFORMERS_BY_IDS_QUERY, {"ids": ids})
|
|
132
|
+
except core.CliError as error:
|
|
133
|
+
match = _PERFORMER_NOT_FOUND.search(str(error))
|
|
134
|
+
if error.code == "graphql_error" and match:
|
|
135
|
+
raise _performer_not_found([match.group(1)]) from error
|
|
136
|
+
raise
|
|
137
|
+
performers = data.get("findPerformers", {}).get("performers", [])
|
|
138
|
+
found = {performer["id"] for performer in performers}
|
|
139
|
+
missing = [performer_id for performer_id in ids if performer_id not in found]
|
|
140
|
+
if missing:
|
|
141
|
+
raise _performer_not_found(missing)
|
|
142
|
+
return performers
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_BULK_UPDATE_PERFORMER_MUTATION = """
|
|
146
|
+
mutation BulkUpdatePerformer($input: BulkPerformerUpdateInput!) {
|
|
147
|
+
bulkPerformerUpdate(input: $input) {
|
|
148
|
+
id
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def bulk_update_performer(input_data: dict) -> str:
|
|
155
|
+
"""Send a one-performer bulkPerformerUpdate and return the performer id."""
|
|
156
|
+
(performer_id,) = input_data["ids"]
|
|
157
|
+
try:
|
|
158
|
+
data = core.call(_BULK_UPDATE_PERFORMER_MUTATION, {"input": input_data})
|
|
159
|
+
except core.CliError as error:
|
|
160
|
+
# Stash's error for a bulkPerformerUpdate of a missing performer.
|
|
161
|
+
not_found = rf"\bperformer with id {re.escape(performer_id)} not found\b"
|
|
162
|
+
if error.code == "graphql_error" and re.search(not_found, str(error)):
|
|
163
|
+
raise core.not_found_error("performer", performer_id) from error
|
|
164
|
+
raise
|
|
165
|
+
return core.returned_id("bulkPerformerUpdate", data, performer_id)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_MERGE_PERFORMERS_MUTATION = """
|
|
169
|
+
mutation MergePerformers($input: PerformerMergeInput!) {
|
|
170
|
+
performerMerge(input: $input) {
|
|
171
|
+
id
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def merge_performers(input_data: dict) -> str:
|
|
178
|
+
data = core.call(_MERGE_PERFORMERS_MUTATION, {"input": input_data})
|
|
179
|
+
return data["performerMerge"]["id"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Scene lookups and writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .. import core
|
|
8
|
+
|
|
9
|
+
_SCENE_FIELDS = """
|
|
10
|
+
id
|
|
11
|
+
title
|
|
12
|
+
code
|
|
13
|
+
details
|
|
14
|
+
date
|
|
15
|
+
director
|
|
16
|
+
urls
|
|
17
|
+
custom_fields
|
|
18
|
+
studio { id }
|
|
19
|
+
tags { id }
|
|
20
|
+
performers { id }
|
|
21
|
+
groups { group { id } }
|
|
22
|
+
files { path }
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_FIND_SCENES_QUERY = f"""
|
|
26
|
+
query FindScenes($scene_filter: SceneFilterType!, $page: Int!, $per_page: Int!) {{
|
|
27
|
+
findScenes(
|
|
28
|
+
scene_filter: $scene_filter
|
|
29
|
+
filter: {{page: $page, per_page: $per_page, sort: "path", direction: ASC}}
|
|
30
|
+
) {{
|
|
31
|
+
count
|
|
32
|
+
scenes {{
|
|
33
|
+
{_SCENE_FIELDS}
|
|
34
|
+
}}
|
|
35
|
+
}}
|
|
36
|
+
}}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_GET_SCENE_QUERY = f"""
|
|
41
|
+
query GetScene($scene_ids: [Int!]) {{
|
|
42
|
+
findScenes(scene_filter: {{}}, filter: {{per_page: 1}}, scene_ids: $scene_ids) {{
|
|
43
|
+
scenes {{
|
|
44
|
+
{_SCENE_FIELDS}
|
|
45
|
+
}}
|
|
46
|
+
}}
|
|
47
|
+
}}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_scene(scene_id: str) -> dict | None:
|
|
52
|
+
"""Fetch a single scene by id, in the same shape find_scenes returns."""
|
|
53
|
+
try:
|
|
54
|
+
data = core.call(_GET_SCENE_QUERY, {"scene_ids": [int(scene_id)]})
|
|
55
|
+
except core.CliError as error:
|
|
56
|
+
if re.search(r"\bscene with id\b.*\bnot found\b", str(error), re.IGNORECASE):
|
|
57
|
+
return None
|
|
58
|
+
raise
|
|
59
|
+
scenes = data.get("findScenes", {}).get("scenes", [])
|
|
60
|
+
return scenes[0] if scenes else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_scenes(scene_filter: dict, page: int, per_page: int = 50) -> dict:
|
|
64
|
+
"""List scenes matching an arbitrary Stash scene filter, as-is."""
|
|
65
|
+
return core.call(
|
|
66
|
+
_FIND_SCENES_QUERY,
|
|
67
|
+
{"scene_filter": scene_filter, "page": page, "per_page": per_page},
|
|
68
|
+
)["findScenes"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_UPDATE_SCENE_MUTATION = """
|
|
72
|
+
mutation UpdateScene($input: SceneUpdateInput!) {
|
|
73
|
+
sceneUpdate(input: $input) {
|
|
74
|
+
id
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def update_scene(input_data: dict) -> str:
|
|
81
|
+
data = core.call(_UPDATE_SCENE_MUTATION, {"input": input_data})
|
|
82
|
+
return data["sceneUpdate"]["id"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_BULK_UPDATE_SCENE_MUTATION = """
|
|
86
|
+
mutation BulkUpdateScene($input: BulkSceneUpdateInput!) {
|
|
87
|
+
bulkSceneUpdate(input: $input) {
|
|
88
|
+
id
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# Stash's error for a bulkSceneUpdate of a scene that does not exist, when no
|
|
94
|
+
# list row is written (an empty list).
|
|
95
|
+
_BULK_SCENE_NOT_FOUND = "sql: no rows in result set"
|
|
96
|
+
# Stash's error when a list row names a scene or a list value that does not
|
|
97
|
+
# exist. The message does not say which, so the scene is read to tell them apart.
|
|
98
|
+
_BULK_SCENE_FOREIGN_KEY = "FOREIGN KEY constraint failed"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bulk_update_scene(input_data: dict) -> str:
|
|
102
|
+
"""Send a one-scene bulkSceneUpdate and return the scene id."""
|
|
103
|
+
(scene_id,) = input_data["ids"]
|
|
104
|
+
try:
|
|
105
|
+
data = core.call(_BULK_UPDATE_SCENE_MUTATION, {"input": input_data})
|
|
106
|
+
except core.CliError as error:
|
|
107
|
+
if error.code != "graphql_error":
|
|
108
|
+
raise
|
|
109
|
+
if _BULK_SCENE_NOT_FOUND in str(error) or (
|
|
110
|
+
_BULK_SCENE_FOREIGN_KEY in str(error) and get_scene_lists(scene_id) is None
|
|
111
|
+
):
|
|
112
|
+
raise core.not_found_error("scene", scene_id) from error
|
|
113
|
+
raise
|
|
114
|
+
return core.returned_id("bulkSceneUpdate", data, scene_id)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_GET_SCENE_LISTS_QUERY = """
|
|
118
|
+
query GetSceneLists($scene_ids: [Int!]) {
|
|
119
|
+
findScenes(scene_filter: {}, filter: {per_page: 1}, scene_ids: $scene_ids) {
|
|
120
|
+
scenes {
|
|
121
|
+
id
|
|
122
|
+
groups { group { id } scene_index }
|
|
123
|
+
stash_ids { endpoint stash_id updated_at }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_scene_lists(scene_id: str) -> dict | None:
|
|
131
|
+
"""A scene's groups with scene_index and its stash_ids; None if missing."""
|
|
132
|
+
try:
|
|
133
|
+
data = core.call(_GET_SCENE_LISTS_QUERY, {"scene_ids": [int(scene_id)]})
|
|
134
|
+
except core.CliError as error:
|
|
135
|
+
if re.search(r"\bscene with id\b.*\bnot found\b", str(error), re.IGNORECASE):
|
|
136
|
+
return None
|
|
137
|
+
raise
|
|
138
|
+
scenes = data.get("findScenes", {}).get("scenes", [])
|
|
139
|
+
return scenes[0] if scenes else None
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Scraping (delegates to the scrapers installed in Stash)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import core
|
|
6
|
+
|
|
7
|
+
ScrapedTagFields = """
|
|
8
|
+
fragment ScrapedTagFields on ScrapedTag {
|
|
9
|
+
stored_id
|
|
10
|
+
name
|
|
11
|
+
remote_site_id
|
|
12
|
+
}
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
ScrapedStudioFields = """
|
|
16
|
+
fragment ScrapedStudioFields on ScrapedStudio {
|
|
17
|
+
stored_id
|
|
18
|
+
name
|
|
19
|
+
urls
|
|
20
|
+
details
|
|
21
|
+
aliases
|
|
22
|
+
remote_site_id
|
|
23
|
+
image
|
|
24
|
+
parent { stored_id name }
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
ScrapedPerformerFields = """
|
|
29
|
+
fragment ScrapedPerformerFields on ScrapedPerformer {
|
|
30
|
+
stored_id
|
|
31
|
+
name
|
|
32
|
+
disambiguation
|
|
33
|
+
gender
|
|
34
|
+
urls
|
|
35
|
+
aliases
|
|
36
|
+
birthdate
|
|
37
|
+
death_date
|
|
38
|
+
country
|
|
39
|
+
ethnicity
|
|
40
|
+
hair_color
|
|
41
|
+
eye_color
|
|
42
|
+
height
|
|
43
|
+
weight
|
|
44
|
+
measurements
|
|
45
|
+
fake_tits
|
|
46
|
+
penis_length
|
|
47
|
+
circumcised
|
|
48
|
+
career_start
|
|
49
|
+
career_end
|
|
50
|
+
tattoos
|
|
51
|
+
piercings
|
|
52
|
+
details
|
|
53
|
+
remote_site_id
|
|
54
|
+
tags { ...ScrapedTagFields }
|
|
55
|
+
images
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
ScrapedGroupFields = """
|
|
60
|
+
fragment ScrapedGroupFields on ScrapedGroup {
|
|
61
|
+
stored_id
|
|
62
|
+
name
|
|
63
|
+
aliases
|
|
64
|
+
date
|
|
65
|
+
duration
|
|
66
|
+
rating
|
|
67
|
+
director
|
|
68
|
+
synopsis
|
|
69
|
+
urls
|
|
70
|
+
studio { ...ScrapedStudioFields }
|
|
71
|
+
tags { ...ScrapedTagFields }
|
|
72
|
+
front_image
|
|
73
|
+
back_image
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
ScrapedSceneFields = """
|
|
78
|
+
fragment ScrapedSceneFields on ScrapedScene {
|
|
79
|
+
title
|
|
80
|
+
code
|
|
81
|
+
details
|
|
82
|
+
director
|
|
83
|
+
urls
|
|
84
|
+
date
|
|
85
|
+
duration
|
|
86
|
+
remote_site_id
|
|
87
|
+
image
|
|
88
|
+
studio { ...ScrapedStudioFields }
|
|
89
|
+
tags { ...ScrapedTagFields }
|
|
90
|
+
performers { ...ScrapedPerformerFields }
|
|
91
|
+
groups { ...ScrapedGroupFields }
|
|
92
|
+
}
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_FRAGMENTS = {
|
|
96
|
+
"ScrapedTagFields": ScrapedTagFields,
|
|
97
|
+
"ScrapedStudioFields": ScrapedStudioFields,
|
|
98
|
+
"ScrapedPerformerFields": ScrapedPerformerFields,
|
|
99
|
+
"ScrapedGroupFields": ScrapedGroupFields,
|
|
100
|
+
"ScrapedSceneFields": ScrapedSceneFields,
|
|
101
|
+
}
|
|
102
|
+
_FRAGMENT_DEPENDENCIES = {
|
|
103
|
+
"ScrapedTagFields": (),
|
|
104
|
+
"ScrapedStudioFields": (),
|
|
105
|
+
"ScrapedPerformerFields": ("ScrapedTagFields",),
|
|
106
|
+
"ScrapedGroupFields": ("ScrapedStudioFields", "ScrapedTagFields"),
|
|
107
|
+
"ScrapedSceneFields": (
|
|
108
|
+
"ScrapedStudioFields",
|
|
109
|
+
"ScrapedTagFields",
|
|
110
|
+
"ScrapedPerformerFields",
|
|
111
|
+
"ScrapedGroupFields",
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _fragments(*names: str) -> str:
|
|
117
|
+
"""Return requested fragments and their dependencies exactly once."""
|
|
118
|
+
included: set[str] = set()
|
|
119
|
+
ordered: list[str] = []
|
|
120
|
+
|
|
121
|
+
def add(name: str) -> None:
|
|
122
|
+
if name in included:
|
|
123
|
+
return
|
|
124
|
+
for dependency in _FRAGMENT_DEPENDENCIES[name]:
|
|
125
|
+
add(dependency)
|
|
126
|
+
included.add(name)
|
|
127
|
+
ordered.append(name)
|
|
128
|
+
|
|
129
|
+
for name in names:
|
|
130
|
+
add(name)
|
|
131
|
+
return "\n".join(_FRAGMENTS[name] for name in ordered)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_SCRAPE_GROUP_URL_QUERY = """
|
|
135
|
+
query ScrapeGroupURL($url: String!) {
|
|
136
|
+
scrapeGroupURL(url: $url) { ...ScrapedGroupFields }
|
|
137
|
+
}
|
|
138
|
+
""" + _fragments("ScrapedGroupFields")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def scrape_group_url(url: str) -> dict | None:
|
|
142
|
+
return core.call(_SCRAPE_GROUP_URL_QUERY, {"url": url}).get("scrapeGroupURL")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_SCRAPE_SCENE_URL_QUERY = """
|
|
146
|
+
query ScrapeSceneURL($url: String!) {
|
|
147
|
+
scrapeSceneURL(url: $url) { ...ScrapedSceneFields }
|
|
148
|
+
}
|
|
149
|
+
""" + _fragments("ScrapedSceneFields")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def scrape_scene_url(url: str) -> dict | None:
|
|
153
|
+
"""Scrape a URL and return the full scraped scene, if one is found."""
|
|
154
|
+
return core.call(_SCRAPE_SCENE_URL_QUERY, {"url": url}).get("scrapeSceneURL")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_SCRAPE_PERFORMER_URL_QUERY = """
|
|
158
|
+
query ScrapePerformerURL($url: String!) {
|
|
159
|
+
scrapePerformerURL(url: $url) { ...ScrapedPerformerFields }
|
|
160
|
+
}
|
|
161
|
+
""" + _fragments("ScrapedPerformerFields")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def scrape_performer_url(url: str) -> dict | None:
|
|
165
|
+
return core.call(_SCRAPE_PERFORMER_URL_QUERY, {"url": url}).get(
|
|
166
|
+
"scrapePerformerURL"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_SCRAPE_SINGLE_SCENE_QUERY = """
|
|
171
|
+
query ScrapeSingleScene($source: ScraperSourceInput!, $input: ScrapeSingleSceneInput!) {
|
|
172
|
+
scrapeSingleScene(source: $source, input: $input) { ...ScrapedSceneFields }
|
|
173
|
+
}
|
|
174
|
+
""" + _fragments("ScrapedSceneFields")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def scrape_single_scene(source: dict, input: dict) -> list[dict]:
|
|
178
|
+
return (
|
|
179
|
+
core.call(_SCRAPE_SINGLE_SCENE_QUERY, {"source": source, "input": input}).get(
|
|
180
|
+
"scrapeSingleScene"
|
|
181
|
+
)
|
|
182
|
+
or []
|
|
183
|
+
)
|