shutterstock-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ check:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ['3.10', '3.12', '3.14']
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v7
17
+ with:
18
+ python-version: ${{ matrix.python }}
19
+ - run: uv venv && uv pip install -e . pytest anyio ruff mypy
20
+ - run: .venv/bin/ruff format --check . && .venv/bin/ruff check .
21
+ - run: .venv/bin/mypy
22
+ - run: .venv/bin/pytest
@@ -0,0 +1,6 @@
1
+ .venv
2
+ __pycache__
3
+ dist
4
+ .pytest_cache
5
+ .mypy_cache
6
+ .ruff_cache
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manfred Obumma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: shutterstock-mcp
3
+ Version: 0.1.0
4
+ Summary: An MCP server for Shutterstock: search photos, footage, music and editorial images, look at previews, find similar images, manage collections, and license with your own subscription.
5
+ Project-URL: Homepage, https://github.com/fredobumma/shutterstock-mcp
6
+ Project-URL: Issues, https://github.com/fredobumma/shutterstock-mcp/issues
7
+ Author-email: Manfred Obumma <fredobumma@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: claude,mcp,model-context-protocol,shutterstock,stock-footage,stock-photos
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.27
13
+ Requires-Dist: mcp<3,>=2.3
14
+ Description-Content-Type: text/markdown
15
+
16
+ # shutterstock-mcp
17
+
18
+ <!-- mcp-name: io.github.Fredobumma/shutterstock-mcp -->
19
+
20
+ An MCP server for [Shutterstock](https://www.shutterstock.com). It lets Claude and other assistants search Shutterstock's photos, illustrations, vectors, video footage, music and editorial news photos, look at previews, find similar images, keep picks in collections, and, if you turn it on, license what you've chosen with your own subscription.
21
+
22
+ ## Why
23
+
24
+ Finding the right stock asset is slow, manual work: search, scroll, preview, compare, search again. An assistant helping with a design, a deck, a video edit or an article can't reach Shutterstock at all. Shutterstock has no official MCP server, and what exists is a demo that returns page links.
25
+
26
+ ## What you can ask for
27
+
28
+ - "Find warm, candid photos of a small team in an office, no faces, landscape, that would work on a dark website header." The assistant searches, looks at the previews, and shows you the ones that fit.
29
+ - "Find a 15 to 30 second drone shot of a coastline in 4K, and an uplifting instrumental track around 100 BPM to go under it."
30
+ - "Find editorial photos of last night's final for the match report."
31
+ - "Here's a sketch of the layout we want. Find stock photos that look like it." This is reverse image search, from a file or a URL.
32
+ - "Find more like this one." That works for images and video.
33
+ - "Put these five in the *Spring campaign* collection" for the team to review.
34
+ - "License the second one at full size." This needs licensing turned on; see below.
35
+
36
+ ## Setup
37
+
38
+ 1. Create a free app at [shutterstock.com/account/developers/apps](https://www.shutterstock.com/account/developers/apps). Its **key and secret** are enough to search.
39
+ 2. For collections, license history and licensing, the server also needs an **OAuth access token** for your account. You can generate one from the same app page, with the scopes you want: `user.view`, `collections.view`, `collections.edit`, `licenses.view` and `licenses.create`.
40
+
41
+ ### Claude Code
42
+
43
+ ```sh
44
+ claude mcp add shutterstock -e SHUTTERSTOCK_API_KEY=… -e SHUTTERSTOCK_API_SECRET=… -- uvx shutterstock-mcp
45
+ ```
46
+
47
+ ### Claude Desktop, Cursor and others
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "shutterstock": {
53
+ "command": "uvx",
54
+ "args": ["shutterstock-mcp"],
55
+ "env": {
56
+ "SHUTTERSTOCK_API_KEY": "…",
57
+ "SHUTTERSTOCK_API_SECRET": "…"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ With an OAuth token, set `SHUTTERSTOCK_API_TOKEN` instead of the key and secret.
65
+
66
+ ### Licensing
67
+
68
+ Licensing spends your subscription's downloads or credits and can't be undone, so the `license_asset` tool doesn't exist unless you set `SHUTTERSTOCK_ENABLE_LICENSING=true`. When it's on, it's marked as destructive, so clients that respect tool hints ask you before each use. Editorial photos are licensed only once you've confirmed they're for news or commentary.
69
+
70
+ ## Tools
71
+
72
+ | Tool | Needs | |
73
+ | --- | --- | --- |
74
+ | `search_images` | key | Photos, illustrations and vectors, by orientation, colour, people, licence and more |
75
+ | `search_videos` | key | Footage, by resolution, length, aspect ratio and orientation |
76
+ | `search_music` | key | Tracks, by genre, mood, instruments, tempo, length and vocals |
77
+ | `search_editorial` | key | News, sport and entertainment photos, by date, cleared for a country |
78
+ | `get_asset` | key | Full details of one asset |
79
+ | `find_similar` | key | More images or videos like one from Shutterstock |
80
+ | `reverse_image_search` | key | Shutterstock images like one of your own |
81
+ | `view_preview` | key | The watermarked preview, as an image the assistant can look at |
82
+ | `list_subscriptions` | token | Your subscriptions and what's left on them |
83
+ | `list_licenses` | token | What you've licensed before |
84
+ | `list_collections`, `get_collection` | token | Your collections and what's in them |
85
+ | `create_collection`, `add_to_collection` | token | Make and fill collections |
86
+ | `license_asset` | token, opt-in | License an image, clip, track or editorial photo |
87
+
88
+ Free Shutterstock apps search a smaller part of the library, see only the first results of a search, and are limited to about 100 requests an hour.
89
+
90
+ ## Status
91
+
92
+ Built from Shutterstock's API reference and its official SDK's models, and tested against a fake of the API that checks what the server sends, through a real MCP client. It hasn't yet been run against the live API; if something doesn't match, please [open an issue](https://github.com/fredobumma/shutterstock-mcp/issues).
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ uv venv && uv pip install -e . pytest anyio ruff mypy
98
+ pytest && ruff check . && mypy
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT. Not affiliated with Shutterstock. Shutterstock is a trademark of Shutterstock, Inc.
@@ -0,0 +1,88 @@
1
+ # shutterstock-mcp
2
+
3
+ <!-- mcp-name: io.github.Fredobumma/shutterstock-mcp -->
4
+
5
+ An MCP server for [Shutterstock](https://www.shutterstock.com). It lets Claude and other assistants search Shutterstock's photos, illustrations, vectors, video footage, music and editorial news photos, look at previews, find similar images, keep picks in collections, and, if you turn it on, license what you've chosen with your own subscription.
6
+
7
+ ## Why
8
+
9
+ Finding the right stock asset is slow, manual work: search, scroll, preview, compare, search again. An assistant helping with a design, a deck, a video edit or an article can't reach Shutterstock at all. Shutterstock has no official MCP server, and what exists is a demo that returns page links.
10
+
11
+ ## What you can ask for
12
+
13
+ - "Find warm, candid photos of a small team in an office, no faces, landscape, that would work on a dark website header." The assistant searches, looks at the previews, and shows you the ones that fit.
14
+ - "Find a 15 to 30 second drone shot of a coastline in 4K, and an uplifting instrumental track around 100 BPM to go under it."
15
+ - "Find editorial photos of last night's final for the match report."
16
+ - "Here's a sketch of the layout we want. Find stock photos that look like it." This is reverse image search, from a file or a URL.
17
+ - "Find more like this one." That works for images and video.
18
+ - "Put these five in the *Spring campaign* collection" for the team to review.
19
+ - "License the second one at full size." This needs licensing turned on; see below.
20
+
21
+ ## Setup
22
+
23
+ 1. Create a free app at [shutterstock.com/account/developers/apps](https://www.shutterstock.com/account/developers/apps). Its **key and secret** are enough to search.
24
+ 2. For collections, license history and licensing, the server also needs an **OAuth access token** for your account. You can generate one from the same app page, with the scopes you want: `user.view`, `collections.view`, `collections.edit`, `licenses.view` and `licenses.create`.
25
+
26
+ ### Claude Code
27
+
28
+ ```sh
29
+ claude mcp add shutterstock -e SHUTTERSTOCK_API_KEY=… -e SHUTTERSTOCK_API_SECRET=… -- uvx shutterstock-mcp
30
+ ```
31
+
32
+ ### Claude Desktop, Cursor and others
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "shutterstock": {
38
+ "command": "uvx",
39
+ "args": ["shutterstock-mcp"],
40
+ "env": {
41
+ "SHUTTERSTOCK_API_KEY": "…",
42
+ "SHUTTERSTOCK_API_SECRET": "…"
43
+ }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ With an OAuth token, set `SHUTTERSTOCK_API_TOKEN` instead of the key and secret.
50
+
51
+ ### Licensing
52
+
53
+ Licensing spends your subscription's downloads or credits and can't be undone, so the `license_asset` tool doesn't exist unless you set `SHUTTERSTOCK_ENABLE_LICENSING=true`. When it's on, it's marked as destructive, so clients that respect tool hints ask you before each use. Editorial photos are licensed only once you've confirmed they're for news or commentary.
54
+
55
+ ## Tools
56
+
57
+ | Tool | Needs | |
58
+ | --- | --- | --- |
59
+ | `search_images` | key | Photos, illustrations and vectors, by orientation, colour, people, licence and more |
60
+ | `search_videos` | key | Footage, by resolution, length, aspect ratio and orientation |
61
+ | `search_music` | key | Tracks, by genre, mood, instruments, tempo, length and vocals |
62
+ | `search_editorial` | key | News, sport and entertainment photos, by date, cleared for a country |
63
+ | `get_asset` | key | Full details of one asset |
64
+ | `find_similar` | key | More images or videos like one from Shutterstock |
65
+ | `reverse_image_search` | key | Shutterstock images like one of your own |
66
+ | `view_preview` | key | The watermarked preview, as an image the assistant can look at |
67
+ | `list_subscriptions` | token | Your subscriptions and what's left on them |
68
+ | `list_licenses` | token | What you've licensed before |
69
+ | `list_collections`, `get_collection` | token | Your collections and what's in them |
70
+ | `create_collection`, `add_to_collection` | token | Make and fill collections |
71
+ | `license_asset` | token, opt-in | License an image, clip, track or editorial photo |
72
+
73
+ Free Shutterstock apps search a smaller part of the library, see only the first results of a search, and are limited to about 100 requests an hour.
74
+
75
+ ## Status
76
+
77
+ Built from Shutterstock's API reference and its official SDK's models, and tested against a fake of the API that checks what the server sends, through a real MCP client. It hasn't yet been run against the live API; if something doesn't match, please [open an issue](https://github.com/fredobumma/shutterstock-mcp/issues).
78
+
79
+ ## Development
80
+
81
+ ```sh
82
+ uv venv && uv pip install -e . pytest anyio ruff mypy
83
+ pytest && ruff check . && mypy
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT. Not affiliated with Shutterstock. Shutterstock is a trademark of Shutterstock, Inc.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shutterstock-mcp"
7
+ version = "0.1.0"
8
+ description = "An MCP server for Shutterstock: search photos, footage, music and editorial images, look at previews, find similar images, manage collections, and license with your own subscription."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Manfred Obumma", email = "fredobumma@gmail.com" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["mcp", "model-context-protocol", "shutterstock", "stock-photos", "stock-footage", "claude"]
15
+ dependencies = ["mcp>=2.3,<3", "httpx>=0.27"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/fredobumma/shutterstock-mcp"
19
+ Issues = "https://github.com/fredobumma/shutterstock-mcp/issues"
20
+
21
+ [project.scripts]
22
+ shutterstock-mcp = "shutterstock_mcp.server:main"
23
+
24
+ [dependency-groups]
25
+ dev = ["pytest>=9", "anyio>=4", "ruff>=0.17", "mypy>=2"]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/shutterstock_mcp"]
29
+
30
+ [tool.ruff]
31
+ line-length = 120
32
+ target-version = "py310"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
36
+
37
+ [tool.ruff.format]
38
+ quote-style = "single"
39
+
40
+ [tool.mypy]
41
+ strict = true
42
+ files = ["src", "tests"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.Fredobumma/shutterstock-mcp",
4
+ "title": "Shutterstock",
5
+ "description": "Search Shutterstock photos, footage, music and news images; preview, collect and license them.",
6
+ "repository": {
7
+ "url": "https://github.com/fredobumma/shutterstock-mcp",
8
+ "source": "github"
9
+ },
10
+ "version": "0.1.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "identifier": "shutterstock-mcp",
15
+ "version": "0.1.0",
16
+ "transport": {
17
+ "type": "stdio"
18
+ },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "SHUTTERSTOCK_API_KEY",
22
+ "description": "Your Shutterstock app's consumer key, for searching",
23
+ "isSecret": true
24
+ },
25
+ {
26
+ "name": "SHUTTERSTOCK_API_SECRET",
27
+ "description": "Your Shutterstock app's consumer secret",
28
+ "isSecret": true
29
+ },
30
+ {
31
+ "name": "SHUTTERSTOCK_API_TOKEN",
32
+ "description": "Or an OAuth access token, for collections, license history and licensing",
33
+ "isSecret": true
34
+ },
35
+ {
36
+ "name": "SHUTTERSTOCK_ENABLE_LICENSING",
37
+ "description": "Set to true to add the license_asset tool, which spends your subscription"
38
+ }
39
+ ]
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,3 @@
1
+ """An MCP server for Shutterstock."""
2
+
3
+ __version__ = '0.1.0'
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,265 @@
1
+ """A small client for the Shutterstock API, and the compact summaries the tools return."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Literal
9
+
10
+ import httpx
11
+ from mcp.server.mcpserver.exceptions import ToolError
12
+
13
+ API_URL = 'https://api.shutterstock.com/v2'
14
+ USER_AGENT = 'shutterstock-mcp (+https://github.com/fredobumma/shutterstock-mcp)'
15
+
16
+ AssetType = Literal['image', 'video', 'music', 'editorial']
17
+ PATHS: dict[str, str] = {'image': 'images', 'video': 'videos', 'music': 'audio', 'editorial': 'editorial/images'}
18
+
19
+
20
+ class ShutterstockError(ToolError):
21
+ """An error to show the user as it is: what went wrong, and what to do about it. As a ToolError, its message
22
+ reaches the model; anything else that goes wrong stays a generic error, with the details in the server's log."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Credentials:
27
+ token: str | None = None
28
+ key: str | None = None
29
+ secret: str | None = None
30
+
31
+ @classmethod
32
+ def from_env(cls, env: dict[str, str] | None = None) -> Credentials:
33
+ env = dict(os.environ if env is None else env)
34
+ credentials = cls(
35
+ token=env.get('SHUTTERSTOCK_API_TOKEN') or None,
36
+ key=env.get('SHUTTERSTOCK_API_KEY') or None,
37
+ secret=env.get('SHUTTERSTOCK_API_SECRET') or None,
38
+ )
39
+ if not credentials.token and not (credentials.key and credentials.secret):
40
+ raise ShutterstockError(
41
+ 'set SHUTTERSTOCK_API_TOKEN, or SHUTTERSTOCK_API_KEY and SHUTTERSTOCK_API_SECRET: '
42
+ 'create an app at https://www.shutterstock.com/account/developers/apps'
43
+ )
44
+ return credentials
45
+
46
+ @property
47
+ def has_token(self) -> bool:
48
+ return self.token is not None
49
+
50
+
51
+ class Shutterstock:
52
+ def __init__(
53
+ self,
54
+ credentials: Credentials,
55
+ *,
56
+ base_url: str = API_URL,
57
+ transport: httpx.AsyncBaseTransport | None = None,
58
+ ) -> None:
59
+ self.credentials = credentials
60
+ headers = {'User-Agent': USER_AGENT, 'Accept': 'application/json'}
61
+ auth: httpx.Auth | None = None
62
+ if credentials.token:
63
+ headers['Authorization'] = f'Bearer {credentials.token}'
64
+ else:
65
+ auth = httpx.BasicAuth(credentials.key or '', credentials.secret or '')
66
+ self._http = httpx.AsyncClient(
67
+ base_url=base_url.rstrip('/'), headers=headers, auth=auth, transport=transport, timeout=30
68
+ )
69
+ # previews come from Shutterstock's CDN, which needs no credentials and is not sent them
70
+ self._cdn = httpx.AsyncClient(headers={'User-Agent': USER_AGENT}, transport=transport, timeout=30)
71
+
72
+ async def aclose(self) -> None:
73
+ await self._http.aclose()
74
+ await self._cdn.aclose()
75
+
76
+ def require_token(self, what: str, scope: str) -> None:
77
+ """Account features need a user's OAuth token, not just an app's key and secret."""
78
+ if not self.credentials.has_token:
79
+ raise ShutterstockError(
80
+ f'{what} needs an OAuth access token with the {scope} scope, in SHUTTERSTOCK_API_TOKEN; '
81
+ 'an app key and secret only search. Get a token from your app at '
82
+ 'https://www.shutterstock.com/account/developers/apps'
83
+ )
84
+
85
+ async def request(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ params: dict[str, Any] | None = None,
91
+ json: Any = None,
92
+ ) -> Any:
93
+ query = {k: v for k, v in (params or {}).items() if v is not None}
94
+ # booleans as the API spells them, and lists as repeated parameters
95
+ query = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in query.items()}
96
+ try:
97
+ response = await self._http.request(method, path, params=query, json=json)
98
+ except httpx.HTTPError as e:
99
+ raise ShutterstockError(f'could not reach Shutterstock: {e}') from e
100
+ if response.status_code == 429:
101
+ raise ShutterstockError(
102
+ 'Shutterstock says too many requests; free accounts allow about 100 an hour. Try again later.'
103
+ )
104
+ if response.status_code in (401, 403):
105
+ raise ShutterstockError(f'Shutterstock refused the request ({response.status_code}): {_message(response)}')
106
+ if response.status_code == 404:
107
+ raise ShutterstockError(f'not found: {_message(response)}')
108
+ if response.status_code >= 400:
109
+ raise ShutterstockError(f'Shutterstock returned {response.status_code}: {_message(response)}')
110
+ return response.json() if response.content else {}
111
+
112
+ async def fetch_bytes(self, url: str) -> tuple[bytes, str]:
113
+ """A preview image, from Shutterstock's own CDN."""
114
+ if not url.startswith('https://'):
115
+ raise ShutterstockError(f'not a preview URL: {url}')
116
+ try:
117
+ response = await self._cdn.get(url, headers={'Accept': 'image/*'})
118
+ response.raise_for_status()
119
+ except httpx.HTTPError as e:
120
+ raise ShutterstockError(f'could not fetch the preview: {e}') from e
121
+ return response.content, response.headers.get('content-type', 'image/jpeg').split(';')[0]
122
+
123
+
124
+ def _message(response: httpx.Response) -> str:
125
+ try:
126
+ data = response.json()
127
+ except ValueError:
128
+ return response.text[:300] or response.reason_phrase
129
+ if isinstance(data, dict):
130
+ parts = [str(data.get('message', ''))]
131
+ for error in data.get('errors', []) or []:
132
+ if isinstance(error, dict):
133
+ parts.append(str(error.get('message', '')))
134
+ return '; '.join(p for p in parts if p) or response.reason_phrase
135
+ return response.reason_phrase
136
+
137
+
138
+ def encode_file(path: str) -> str:
139
+ try:
140
+ with open(os.path.expanduser(path), 'rb') as f:
141
+ data = f.read()
142
+ except OSError as e:
143
+ raise ShutterstockError(f'could not read {path}: {e.strerror}') from e
144
+ if len(data) > 10 * 1024 * 1024:
145
+ raise ShutterstockError('the image is larger than the 10 MB Shutterstock accepts')
146
+ return base64.b64encode(data).decode()
147
+
148
+
149
+ # ---- summaries -------------------------------------------------------------------------------------------------
150
+ # The API's responses are large and deeply nested. The tools hand back what a person choosing an asset needs: what it
151
+ # is, a preview to look at, and the ids and links to act on it.
152
+
153
+
154
+ def _asset_url(assets: dict[str, Any], *names: str) -> str | None:
155
+ for name in names:
156
+ asset = assets.get(name)
157
+ if isinstance(asset, dict) and asset.get('url'):
158
+ return str(asset['url'])
159
+ return None
160
+
161
+
162
+ def _clean(data: dict[str, Any]) -> dict[str, Any]:
163
+ return {k: v for k, v in data.items() if v not in (None, [], '')}
164
+
165
+
166
+ def summarize_image(item: dict[str, Any]) -> dict[str, Any]:
167
+ assets = item.get('assets') or {}
168
+ return _clean(
169
+ {
170
+ 'id': item.get('id'),
171
+ 'description': item.get('description'),
172
+ 'type': item.get('image_type'),
173
+ 'aspect': item.get('aspect'),
174
+ 'preview': _asset_url(assets, 'preview_1000', 'preview', 'large_thumb'),
175
+ 'thumbnail': _asset_url(assets, 'large_thumb', 'small_thumb'),
176
+ 'contributor': (item.get('contributor') or {}).get('id'),
177
+ 'editorial': item.get('is_editorial'),
178
+ 'model_released': item.get('has_model_release'),
179
+ 'keywords': (item.get('keywords') or [])[:15],
180
+ 'page': item.get('url'),
181
+ }
182
+ )
183
+
184
+
185
+ def summarize_video(item: dict[str, Any]) -> dict[str, Any]:
186
+ assets = item.get('assets') or {}
187
+ return _clean(
188
+ {
189
+ 'id': item.get('id'),
190
+ 'description': item.get('description'),
191
+ 'duration_seconds': item.get('duration'),
192
+ 'aspect_ratio': item.get('aspect_ratio'),
193
+ 'preview_video': _asset_url(assets, 'preview_mp4', 'thumb_mp4'),
194
+ 'preview_image': _asset_url(assets, 'preview_jpg', 'thumb_jpg'),
195
+ 'sizes': [s for s in ('web', 'sd', 'hd', '_4k') if s in assets] or None,
196
+ 'contributor': (item.get('contributor') or {}).get('id'),
197
+ 'editorial': item.get('is_editorial'),
198
+ 'keywords': (item.get('keywords') or [])[:15],
199
+ 'page': item.get('url'),
200
+ }
201
+ )
202
+
203
+
204
+ def summarize_track(item: dict[str, Any]) -> dict[str, Any]:
205
+ assets = item.get('assets') or {}
206
+ return _clean(
207
+ {
208
+ 'id': item.get('id'),
209
+ 'title': item.get('title'),
210
+ 'artists': [a.get('name') for a in item.get('artists') or [] if isinstance(a, dict)],
211
+ 'description': item.get('description'),
212
+ 'duration_seconds': item.get('duration'),
213
+ 'bpm': item.get('bpm'),
214
+ 'genres': item.get('genres'),
215
+ 'moods': item.get('moods'),
216
+ 'instruments': item.get('instruments'),
217
+ 'instrumental': item.get('is_instrumental'),
218
+ 'preview_audio': _asset_url(assets, 'preview_mp3', 'preview_ogg'),
219
+ 'waveform': _asset_url(assets, 'waveform'),
220
+ 'page': item.get('url'),
221
+ }
222
+ )
223
+
224
+
225
+ def summarize_editorial(item: dict[str, Any]) -> dict[str, Any]:
226
+ assets = item.get('assets') or {}
227
+ return _clean(
228
+ {
229
+ 'id': item.get('id'),
230
+ 'title': item.get('title'),
231
+ 'caption': item.get('caption') or item.get('description'),
232
+ 'date_taken': item.get('date_taken'),
233
+ 'byline': item.get('byline'),
234
+ 'supplier': item.get('supplier_code'),
235
+ 'preview': _asset_url(assets, 'watermark_1500', 'watermark_450', 'preview', 'thumb_220', 'thumb_170'),
236
+ 'thumbnail': _asset_url(assets, 'thumb_220', 'thumb_170'),
237
+ 'aspect': item.get('aspect'),
238
+ 'keywords': (item.get('keywords') or [])[:15],
239
+ }
240
+ )
241
+
242
+
243
+ SUMMARIES = {
244
+ 'image': summarize_image,
245
+ 'video': summarize_video,
246
+ 'music': summarize_track,
247
+ 'editorial': summarize_editorial,
248
+ }
249
+
250
+
251
+ def summarize_page(data: dict[str, Any], asset_type: str) -> dict[str, Any]:
252
+ summarize = SUMMARIES[asset_type]
253
+ return _clean(
254
+ {
255
+ 'total': data.get('total_count'),
256
+ 'page': data.get('page'),
257
+ 'per_page': data.get('per_page'),
258
+ 'search_id': data.get('search_id'),
259
+ 'next_cursor': data.get('next'),
260
+ 'did_you_mean': (data.get('spellcheck_info') or {}).get('spellchecked_query')
261
+ if isinstance(data.get('spellcheck_info'), dict)
262
+ else None,
263
+ 'results': [summarize(item) for item in data.get('data') or []],
264
+ }
265
+ )