free-image-library 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.
- free_image_library-0.1.0/LICENSE +21 -0
- free_image_library-0.1.0/PKG-INFO +108 -0
- free_image_library-0.1.0/README.md +81 -0
- free_image_library-0.1.0/free_image_library/__init__.py +3 -0
- free_image_library-0.1.0/free_image_library/__main__.py +6 -0
- free_image_library-0.1.0/free_image_library/credits.py +88 -0
- free_image_library-0.1.0/free_image_library/openverse.py +202 -0
- free_image_library-0.1.0/free_image_library/server.py +561 -0
- free_image_library-0.1.0/free_image_library/telemetry.py +613 -0
- free_image_library-0.1.0/free_image_library.egg-info/PKG-INFO +108 -0
- free_image_library-0.1.0/free_image_library.egg-info/SOURCES.txt +19 -0
- free_image_library-0.1.0/free_image_library.egg-info/dependency_links.txt +1 -0
- free_image_library-0.1.0/free_image_library.egg-info/entry_points.txt +3 -0
- free_image_library-0.1.0/free_image_library.egg-info/requires.txt +6 -0
- free_image_library-0.1.0/free_image_library.egg-info/top_level.txt +1 -0
- free_image_library-0.1.0/pyproject.toml +50 -0
- free_image_library-0.1.0/setup.cfg +4 -0
- free_image_library-0.1.0/tests/test_credits_unit.py +53 -0
- free_image_library-0.1.0/tests/test_e2e_server.py +75 -0
- free_image_library-0.1.0/tests/test_openverse_live.py +66 -0
- free_image_library-0.1.0/tests/test_telemetry.py +44 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Surendran B
|
|
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,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: free-image-library
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Free images for AI agents: search millions of openly-licensed, royalty-free Creative Commons images and audio (Flickr, Wikimedia, museums) — every result with ready-to-paste attribution. Zero API keys.
|
|
5
|
+
Author-email: Surendran B <reachsuren@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://free-image-library.builditwithai.xyz
|
|
8
|
+
Project-URL: Repository, https://github.com/surendranb/free-image-library
|
|
9
|
+
Project-URL: Issues, https://github.com/surendranb/free-image-library/issues
|
|
10
|
+
Keywords: free-images,free-image-library,royalty-free-images,creative-commons-images,stock-images,openverse,image-search,mcp,mcp-server,model-context-protocol,ai-agent,claude,llm,agentic-workflows
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
22
|
+
Requires-Dist: requests>=2.31.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Free Image Library — Free CC Images & Audio MCP 🖼️
|
|
29
|
+
|
|
30
|
+
[](https://github.com/surendranb/free-image-library/actions)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](https://pypi.org/project/free-image-library/)
|
|
33
|
+
|
|
34
|
+
> **Free images for any project: millions of openly-licensed, royalty-free Creative Commons images and audio (Flickr, Wikimedia, museums) — every result with ready-to-paste attribution. Zero API keys, zero configuration.**
|
|
35
|
+
|
|
36
|
+
The visual sibling of the [Free Music Library](https://github.com/surendranb/music-mcp)
|
|
37
|
+
for the same content-creator audience: the agent finds the image AND the credit
|
|
38
|
+
line in one call, so the attribution never gets lost between copy and paste.
|
|
39
|
+
|
|
40
|
+
## Why this exists
|
|
41
|
+
|
|
42
|
+
- Models suggest random stock images (license unknown, credit never written).
|
|
43
|
+
This returns license-safe images with the credit the user MUST paste — built
|
|
44
|
+
into the same result.
|
|
45
|
+
- **License intelligence, not just labels**: every row carries a `credit_note`
|
|
46
|
+
stating the actual obligation — CC BY-NC rows are loudly flagged
|
|
47
|
+
non-commercial-only before your client ships them into a paid campaign.
|
|
48
|
+
- **Serendipity**: `image_roulette` picks from deep in the result set, not the
|
|
49
|
+
first hit everyone has seen.
|
|
50
|
+
- Built on [Openverse](https://api.openverse.org) — keyless (anonymous limits:
|
|
51
|
+
20 requests/min, 200/day, verified; a free registered key raises them via
|
|
52
|
+
`OPENVERSE_API_KEY`). A 5-minute TTL cache makes repeat searches free.
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
| Tool | What it does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `find_images` | Free CC image search with filters (license, aspect_ratio, size, extension) + attribution on every row |
|
|
59
|
+
| `image_roulette` | One random license-safe pick, honest about how it was chosen |
|
|
60
|
+
| `find_audio` | Free CC music / sound effects / field recordings from Openverse's audio index |
|
|
61
|
+
| `skills_list` / `skill_read` | Updatable playbooks: CC license briefs in plain language, error recovery |
|
|
62
|
+
|
|
63
|
+
Plus prompts: `hero-image`, `license-safe-images`.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# 1-Line Universal Installer (auto-configures Claude Desktop, Cursor, Claude Code, VS Code, ...)
|
|
69
|
+
curl -fsSL "https://free-image-library.builditwithai.xyz/install" | bash
|
|
70
|
+
|
|
71
|
+
# Or run directly via your preferred runtime:
|
|
72
|
+
uvx free-image-library
|
|
73
|
+
npx -y free-image-library
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Example
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
User: I need a hero image for my climate blog post
|
|
80
|
+
|
|
81
|
+
find_images(query="melting glacier", aspect_ratio="wide", count=3,
|
|
82
|
+
intent="hero image for a climate blog post")
|
|
83
|
+
→ images: [{
|
|
84
|
+
title: "Glacier calving", creator: "…", license: "CC BY 2.0",
|
|
85
|
+
image_url: "https://…", width: 2048, height: 1152,
|
|
86
|
+
attribution: "\"Glacier calving\" image by … (flickr), licensed CC BY 2.0 (…)",
|
|
87
|
+
credit_note: "Credit REQUIRED — paste the attribution verbatim." }]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Telemetry & privacy
|
|
91
|
+
|
|
92
|
+
Anonymous usage telemetry (no PII, no queries, no paths) via the fleet
|
|
93
|
+
standard (schema v2, dual-endpoint fallback). Opt out any time:
|
|
94
|
+
`FREE_IMAGE_LIBRARY_TELEMETRY=false` or `DO_NOT_TRACK=1`.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv venv && uv pip install -e ".[dev]"
|
|
100
|
+
DO_NOT_TRACK=1 .venv/bin/python -m pytest tests/ -q # unit + live + e2e
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Live tests hit the real Openverse API; they skip themselves when offline or
|
|
104
|
+
rate-limited.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Free Image Library — Free CC Images & Audio MCP 🖼️
|
|
2
|
+
|
|
3
|
+
[](https://github.com/surendranb/free-image-library/actions)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://pypi.org/project/free-image-library/)
|
|
6
|
+
|
|
7
|
+
> **Free images for any project: millions of openly-licensed, royalty-free Creative Commons images and audio (Flickr, Wikimedia, museums) — every result with ready-to-paste attribution. Zero API keys, zero configuration.**
|
|
8
|
+
|
|
9
|
+
The visual sibling of the [Free Music Library](https://github.com/surendranb/music-mcp)
|
|
10
|
+
for the same content-creator audience: the agent finds the image AND the credit
|
|
11
|
+
line in one call, so the attribution never gets lost between copy and paste.
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
- Models suggest random stock images (license unknown, credit never written).
|
|
16
|
+
This returns license-safe images with the credit the user MUST paste — built
|
|
17
|
+
into the same result.
|
|
18
|
+
- **License intelligence, not just labels**: every row carries a `credit_note`
|
|
19
|
+
stating the actual obligation — CC BY-NC rows are loudly flagged
|
|
20
|
+
non-commercial-only before your client ships them into a paid campaign.
|
|
21
|
+
- **Serendipity**: `image_roulette` picks from deep in the result set, not the
|
|
22
|
+
first hit everyone has seen.
|
|
23
|
+
- Built on [Openverse](https://api.openverse.org) — keyless (anonymous limits:
|
|
24
|
+
20 requests/min, 200/day, verified; a free registered key raises them via
|
|
25
|
+
`OPENVERSE_API_KEY`). A 5-minute TTL cache makes repeat searches free.
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `find_images` | Free CC image search with filters (license, aspect_ratio, size, extension) + attribution on every row |
|
|
32
|
+
| `image_roulette` | One random license-safe pick, honest about how it was chosen |
|
|
33
|
+
| `find_audio` | Free CC music / sound effects / field recordings from Openverse's audio index |
|
|
34
|
+
| `skills_list` / `skill_read` | Updatable playbooks: CC license briefs in plain language, error recovery |
|
|
35
|
+
|
|
36
|
+
Plus prompts: `hero-image`, `license-safe-images`.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1-Line Universal Installer (auto-configures Claude Desktop, Cursor, Claude Code, VS Code, ...)
|
|
42
|
+
curl -fsSL "https://free-image-library.builditwithai.xyz/install" | bash
|
|
43
|
+
|
|
44
|
+
# Or run directly via your preferred runtime:
|
|
45
|
+
uvx free-image-library
|
|
46
|
+
npx -y free-image-library
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Example
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
User: I need a hero image for my climate blog post
|
|
53
|
+
|
|
54
|
+
find_images(query="melting glacier", aspect_ratio="wide", count=3,
|
|
55
|
+
intent="hero image for a climate blog post")
|
|
56
|
+
→ images: [{
|
|
57
|
+
title: "Glacier calving", creator: "…", license: "CC BY 2.0",
|
|
58
|
+
image_url: "https://…", width: 2048, height: 1152,
|
|
59
|
+
attribution: "\"Glacier calving\" image by … (flickr), licensed CC BY 2.0 (…)",
|
|
60
|
+
credit_note: "Credit REQUIRED — paste the attribution verbatim." }]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Telemetry & privacy
|
|
64
|
+
|
|
65
|
+
Anonymous usage telemetry (no PII, no queries, no paths) via the fleet
|
|
66
|
+
standard (schema v2, dual-endpoint fallback). Opt out any time:
|
|
67
|
+
`FREE_IMAGE_LIBRARY_TELEMETRY=false` or `DO_NOT_TRACK=1`.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv venv && uv pip install -e ".[dev]"
|
|
73
|
+
DO_NOT_TRACK=1 .venv/bin/python -m pytest tests/ -q # unit + live + e2e
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Live tests hit the real Openverse API; they skip themselves when offline or
|
|
77
|
+
rate-limited.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
"""Attribution builders — the instant-credit pattern from music-mcp.
|
|
4
|
+
|
|
5
|
+
Every result row can be turned into ready-to-paste credit lines in three
|
|
6
|
+
formats. CC BY / BY-SA require credit; CC0/PDM don't (but crediting is good
|
|
7
|
+
practice and we say so rather than silently dropping it).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
_LICENSE_NAMES = {
|
|
11
|
+
"CC0": "CC0 1.0 (public domain)",
|
|
12
|
+
"PDM": "Public Domain Mark",
|
|
13
|
+
"BY": "CC BY",
|
|
14
|
+
"BY-SA": "CC BY-SA",
|
|
15
|
+
"BY-NC": "CC BY-NC",
|
|
16
|
+
"BY-NC-SA": "CC BY-NC-SA",
|
|
17
|
+
"BY-ND": "CC BY-ND",
|
|
18
|
+
"BY-NC-ND": "CC BY-NC-ND",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _license_label(row):
|
|
23
|
+
lic = (row.get("license") or "").upper()
|
|
24
|
+
version = row.get("license_version")
|
|
25
|
+
name = _LICENSE_NAMES.get(lic, lic or "unknown license")
|
|
26
|
+
return f"{name} {version}".strip() if version else name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _creator(row):
|
|
30
|
+
creator = (row.get("creator") or "").strip()
|
|
31
|
+
if not creator:
|
|
32
|
+
return None, None
|
|
33
|
+
source = row.get("source") or "the source"
|
|
34
|
+
url = row.get("foreign_landing_url")
|
|
35
|
+
return creator, f'by <a href="{url}">{creator}</a> ({source})' if url \
|
|
36
|
+
else f"by {creator} ({source})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def credit_lines(row, media_noun="image"):
|
|
40
|
+
"""(plaintext, markdown, html) attribution for one result row."""
|
|
41
|
+
title = row.get("title") or "(untitled)"
|
|
42
|
+
creator, creator_html = _creator(row)
|
|
43
|
+
creator_plain = creator or "unknown creator"
|
|
44
|
+
source = row.get("source") or "the source"
|
|
45
|
+
license_label = _license_label(row)
|
|
46
|
+
license_url = row.get("license_url")
|
|
47
|
+
landing = row.get("foreign_landing_url") or row.get("image_url") \
|
|
48
|
+
or row.get("audio_url") or ""
|
|
49
|
+
|
|
50
|
+
lic_plain = f"{license_label} ({license_url})" if license_url else license_label
|
|
51
|
+
lic_md = f"[{license_label}]({license_url})" if license_url else license_label
|
|
52
|
+
lic_html = (f'<a href="{license_url}">{license_label}</a>'
|
|
53
|
+
if license_url else license_label)
|
|
54
|
+
|
|
55
|
+
title_md = f"[{title}]({landing})" if landing else title
|
|
56
|
+
title_html = f'<a href="{landing}">{title}</a>' if landing else title
|
|
57
|
+
|
|
58
|
+
plain = (f'"{title}" {media_noun} by {creator_plain} ({source}), '
|
|
59
|
+
f"licensed {lic_plain}")
|
|
60
|
+
if creator:
|
|
61
|
+
markdown = (f'*"{title}"* {media_noun} '
|
|
62
|
+
f"{creator_md(creator, landing, source)} — {lic_md}")
|
|
63
|
+
else:
|
|
64
|
+
markdown = f'*"{title}"* {media_noun} from {source} — {lic_md}'
|
|
65
|
+
html = (f"{title_html} {media_noun} {creator_html} — {lic_html}")
|
|
66
|
+
|
|
67
|
+
return plain, markdown, html
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def creator_md(creator, landing, source):
|
|
71
|
+
if not creator:
|
|
72
|
+
return f"from {source}"
|
|
73
|
+
return f"by [{creator}]({landing}) ({source})" if landing else \
|
|
74
|
+
f"by {creator} ({source})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def credit_note(row):
|
|
78
|
+
"""One-line obligation note: what credit this license actually requires."""
|
|
79
|
+
lic = (row.get("license") or "").upper()
|
|
80
|
+
if lic in ("CC0", "PDM"):
|
|
81
|
+
return ("No credit required — but including one is good practice "
|
|
82
|
+
"and helps the creator.")
|
|
83
|
+
if lic in ("BY-NC", "BY-NC-SA", "BY-NC-ND"):
|
|
84
|
+
return ("Credit REQUIRED and commercial use NOT allowed — verify the "
|
|
85
|
+
"use is non-commercial before shipping.")
|
|
86
|
+
if lic:
|
|
87
|
+
return "Credit REQUIRED — paste the attribution verbatim."
|
|
88
|
+
return "License unknown — do not use without checking the source page."
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
"""Openverse API client — CC-licensed images and audio for AI agents.
|
|
4
|
+
|
|
5
|
+
Openverse (WordPress) is keyless: anonymous use is rate-limited to 20/min
|
|
6
|
+
burst and 200/day sustained (verified 2026-08-22), which is plenty for a
|
|
7
|
+
discovery tool with the TTL cache below. A registered key can be supplied
|
|
8
|
+
later via OPENVERSE_API_KEY for higher limits.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
BASE = "https://api.openverse.org/v1"
|
|
18
|
+
USER_AGENT = "free-image-library/0.1.0 (MCP server; +https://github.com/surendranb/free-image-library)"
|
|
19
|
+
TIMEOUT = 10.0
|
|
20
|
+
CACHE_TTL = 300 # 5 min: identical searches within a session cost nothing
|
|
21
|
+
|
|
22
|
+
LICENSES = ("all", "cc", "cc0", "pdm", "by", "by-sa", "by-nc", "by-nc-sa",
|
|
23
|
+
"by-nd", "by-nc-nd")
|
|
24
|
+
ASPECTS = ("wide", "square", "tall")
|
|
25
|
+
SIZES = ("small", "medium", "large")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenverseError(Exception):
|
|
29
|
+
"""Upstream failure with a model-facing tag."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message, rate_limited=False):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.rate_limited = rate_limited
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_cache_lock = threading.Lock()
|
|
37
|
+
_cache = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cached(key):
|
|
41
|
+
now = time.time()
|
|
42
|
+
with _cache_lock:
|
|
43
|
+
hit = _cache.get(key)
|
|
44
|
+
if hit and hit[0] > now:
|
|
45
|
+
return hit[1]
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _store(key, value):
|
|
50
|
+
with _cache_lock:
|
|
51
|
+
if len(_cache) > 128: # bounded: discovery tool, not a mirror
|
|
52
|
+
_cache.clear()
|
|
53
|
+
_cache[key] = (time.time() + CACHE_TTL, value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _headers():
|
|
57
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
58
|
+
key = os.getenv("OPENVERSE_API_KEY") # optional: higher rate limits
|
|
59
|
+
if key:
|
|
60
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
61
|
+
return headers
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get(path, params):
|
|
65
|
+
cache_key = f"{path}?{sorted((params or {}).items())}"
|
|
66
|
+
hit = _cached(cache_key)
|
|
67
|
+
if hit is not None:
|
|
68
|
+
return hit
|
|
69
|
+
try:
|
|
70
|
+
resp = requests.get(f"{BASE}{path}", params=params or {},
|
|
71
|
+
headers=_headers(), timeout=TIMEOUT)
|
|
72
|
+
except requests.Timeout as e:
|
|
73
|
+
raise OpenverseError("Openverse request timed out "
|
|
74
|
+
"[TRANSIENT: retry once]") from e
|
|
75
|
+
except requests.RequestException as e:
|
|
76
|
+
raise OpenverseError(f"Openverse request failed: {e} "
|
|
77
|
+
"[TRANSIENT: retry once]") from e
|
|
78
|
+
if resp.status_code == 429:
|
|
79
|
+
raise OpenverseError(
|
|
80
|
+
"Openverse anonymous rate limit hit (20/min, 200/day). Wait a "
|
|
81
|
+
"minute before the next call; set OPENVERSE_API_KEY for higher "
|
|
82
|
+
"limits [ENVIRONMENT_FIXABLE: wait or register a key at "
|
|
83
|
+
"api.openverse.org]", rate_limited=True)
|
|
84
|
+
if resp.status_code == 401:
|
|
85
|
+
raise OpenverseError("Openverse rejected OPENVERSE_API_KEY "
|
|
86
|
+
"[ENVIRONMENT_FIXABLE: unset it to use anonymous "
|
|
87
|
+
"access, or fix the key]")
|
|
88
|
+
if resp.status_code >= 400:
|
|
89
|
+
raise OpenverseError(f"Openverse returned HTTP {resp.status_code} "
|
|
90
|
+
"[INPUT_FIXABLE: check the arguments, e.g. an "
|
|
91
|
+
"unknown license or aspect value]")
|
|
92
|
+
try:
|
|
93
|
+
data = resp.json()
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
raise OpenverseError("Openverse returned a non-JSON body "
|
|
96
|
+
"[TRANSIENT]") from e
|
|
97
|
+
_store(cache_key, data)
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate(license, aspect_ratio, size, extension):
|
|
102
|
+
if license not in LICENSES:
|
|
103
|
+
raise ValueError(f"Unknown license {license!r}. Candidates: "
|
|
104
|
+
f"{', '.join(LICENSES)}. [INPUT_FIXABLE]")
|
|
105
|
+
if aspect_ratio is not None and aspect_ratio not in ASPECTS:
|
|
106
|
+
raise ValueError(f"Unknown aspect_ratio {aspect_ratio!r}. Candidates: "
|
|
107
|
+
f"{', '.join(ASPECTS)}. [INPUT_FIXABLE]")
|
|
108
|
+
if size is not None and size not in SIZES:
|
|
109
|
+
raise ValueError(f"Unknown size {size!r}. Candidates: "
|
|
110
|
+
f"{', '.join(SIZES)}. [INPUT_FIXABLE]")
|
|
111
|
+
if extension is not None and extension not in ("jpg", "png", "gif", "svg"):
|
|
112
|
+
raise ValueError(f"Unknown extension {extension!r}. Candidates: "
|
|
113
|
+
"jpg, png, gif, svg. [INPUT_FIXABLE]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _params(query, license, aspect_ratio, size, extension, page, page_size):
|
|
117
|
+
params = {"q": query, "page_size": page_size, "page": page,
|
|
118
|
+
"mature": "false"}
|
|
119
|
+
if license == "cc":
|
|
120
|
+
params["license_type"] = "all-cc"
|
|
121
|
+
elif license not in ("all", None):
|
|
122
|
+
params["license"] = license
|
|
123
|
+
if aspect_ratio:
|
|
124
|
+
params["aspect_ratio"] = aspect_ratio
|
|
125
|
+
if size:
|
|
126
|
+
params["size"] = size
|
|
127
|
+
if extension:
|
|
128
|
+
params["extension"] = extension
|
|
129
|
+
return params
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _thumb(url):
|
|
133
|
+
"""Openverse thumbnails are proxied and reliably hotlinkable."""
|
|
134
|
+
if not url:
|
|
135
|
+
return None
|
|
136
|
+
return f"https://api.openverse.org/v1/images/{url}" if not str(url).startswith("http") else url
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _image_row(result):
|
|
140
|
+
return {
|
|
141
|
+
"id": result.get("id"),
|
|
142
|
+
"title": result.get("title") or "(untitled)",
|
|
143
|
+
"creator": result.get("creator"),
|
|
144
|
+
"license": (result.get("license") or "").upper() or None,
|
|
145
|
+
"license_version": result.get("license_version"),
|
|
146
|
+
"license_url": result.get("license_url"),
|
|
147
|
+
"source": result.get("source"),
|
|
148
|
+
"image_url": result.get("url"),
|
|
149
|
+
"thumbnail": result.get("thumbnail"),
|
|
150
|
+
"foreign_landing_url": result.get("foreign_landing_url"),
|
|
151
|
+
"width": result.get("width"),
|
|
152
|
+
"height": result.get("height"),
|
|
153
|
+
"tags": [t.get("name") for t in (result.get("tags") or [])
|
|
154
|
+
if t.get("name")][:8],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _audio_row(result):
|
|
159
|
+
return {
|
|
160
|
+
"id": result.get("id"),
|
|
161
|
+
"title": result.get("title") or "(untitled)",
|
|
162
|
+
"creator": result.get("creator"),
|
|
163
|
+
"license": (result.get("license") or "").upper() or None,
|
|
164
|
+
"license_version": result.get("license_version"),
|
|
165
|
+
"license_url": result.get("license_url"),
|
|
166
|
+
"source": result.get("source"),
|
|
167
|
+
"audio_url": (result.get("url")
|
|
168
|
+
or ((result.get("audio_files") or [{}])[0].get("url"))),
|
|
169
|
+
"duration_s": result.get("duration"),
|
|
170
|
+
"foreign_landing_url": result.get("foreign_landing_url"),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def search_images(query, license="cc", aspect_ratio=None, size=None,
|
|
175
|
+
extension=None, page=1, page_size=10):
|
|
176
|
+
"""CC image search. Returns (rows, result_count)."""
|
|
177
|
+
_validate(license, aspect_ratio, size, extension)
|
|
178
|
+
data = _get("/images/", _params(query, license, aspect_ratio, size,
|
|
179
|
+
extension, page, page_size))
|
|
180
|
+
return ([_image_row(r) for r in data.get("results", [])],
|
|
181
|
+
data.get("result_count", 0))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_image(image_id):
|
|
185
|
+
"""One image record by id (for attribution formatting)."""
|
|
186
|
+
data = _get(f"/images/{image_id}/", {})
|
|
187
|
+
return _image_row(data)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def search_audio(query, license="cc", page=1, page_size=8):
|
|
191
|
+
"""CC audio search (music, sound effects, field recordings)."""
|
|
192
|
+
if license not in LICENSES:
|
|
193
|
+
raise ValueError(f"Unknown license {license!r}. Candidates: "
|
|
194
|
+
f"{', '.join(LICENSES)}. [INPUT_FIXABLE]")
|
|
195
|
+
params = {"q": query, "page_size": page_size, "page": page, "mature": "false"}
|
|
196
|
+
if license == "cc":
|
|
197
|
+
params["license_type"] = "all-cc"
|
|
198
|
+
elif license not in ("all", None):
|
|
199
|
+
params["license"] = license
|
|
200
|
+
data = _get("/audio/", params)
|
|
201
|
+
return ([_audio_row(r) for r in data.get("results", [])],
|
|
202
|
+
data.get("result_count", 0))
|