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,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
+ )