Skip to content

Commit 93e2397

Browse files
committed
Add Google Books metadata provider
- Add optional API-key configuration, provider routing, and normalized metadata.\n- Cover search, caching, visibility, fallback, and API source validation.\n\nFixes #860
1 parent e46d2af commit 93e2397

14 files changed

Lines changed: 534 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ The only universally required variable is `SECRET`. For Docker installs you shou
389389
- `STEAM_API_KEY` - Steam game imports
390390
- `BGG_API_TOKEN` - board game metadata from [BoardGameGeek](https://boardgamegeek.com/using_the_xml_api)
391391
- `HARDCOVER_API` - Hardcover book metadata/imports
392+
- `GOOGLE_BOOKS_API_KEY` - optional Google Books book metadata ([Google Books API](https://developers.google.com/books/docs/v1/using)); supports `GOOGLE_BOOKS_API_KEY_FILE` for Docker secrets
392393
- `COMICVINE_API` - comic metadata
393394
- `LASTFM_API_KEY` - Last.fm integration and scrobble polling
394395
- `MUSICBRAINZ_URL` - custom MusicBrainz-compatible API root, including `/ws/2` (defaults to `https://musicbrainz.org/ws/2`)
@@ -422,6 +423,7 @@ IGDB_SECRET=IGDB_SECRET
422423
STEAM_API_KEY=STEAM_API_SECRET
423424
BGG_API_TOKEN=BGG_API_TOKEN
424425
HARDCOVER_API=HARDCOVER_API
426+
GOOGLE_BOOKS_API_KEY=GOOGLE_BOOKS_API_KEY
425427
COMICVINE_API=COMICVINE_API
426428
LASTFM_API_KEY=LASTFM_API_KEY
427429
SECRET=SECRET

docs/agents/media_type_integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ This document explains how media types are defined and wired through the app so
4949
- Notifications (`src/events/notifications.py`): filters by user-enabled media types and exclusions; formats bodies with unicode icons; Season header labeled “TV Shows,” others uppercase media type.
5050

5151
## Providers, search, sync
52-
- Routing in `src/app/providers/services.py`: tmdb(tv/movie/season/episode), mal/mangaupdates(anime/manga), igdb(game), hardcover/openlibrary(book), comicvine(comic), musicbrainz(music), manual fallback. Each returns a dict with `media_id/source/media_type/title/max_progress/image/synopsis/score/score_count/details/related` (+ runtime/episodes).
52+
- Routing in `src/app/providers/services.py`: tmdb(tv/movie/season/episode), mal/mangaupdates(anime/manga), igdb(game), hardcover/openlibrary/googlebooks(book), comicvine(comic), musicbrainz(music), manual fallback. Each returns a dict with `media_id/source/media_type/title/max_progress/image/synopsis/score/score_count/details/related` (+ runtime/episodes).
5353
- Search routing matches each media type to its provider service and configured sources.
5454
- Music search uses `search_combined()` which returns artists, albums, and tracks from MusicBrainz. Cover art is skipped during search (`skip_cover_art=True`) for performance; art loads when viewing artist/album pages.
5555
- `sync_metadata` view: clears cache key, refetches metadata, updates Item title/image (season also bulk-updates episode posters), and triggers `item.fetch_releases`; blocks manual sources.

docs/architecture/provider-credential-consumers.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ stripped. Last.fm is the only family below without a `_FILE` input.
5454
| Steam | `STEAM_API_KEY` | `STEAM_API_KEY_FILE` | empty |
5555
| BoardGameGeek | `BGG_API_TOKEN` | `BGG_API_TOKEN_FILE` | non-empty shared token |
5656
| Hardcover | `HARDCOVER_API` | `HARDCOVER_API_FILE` | non-empty shared `Bearer` token |
57+
| Google Books | `GOOGLE_BOOKS_API_KEY` | `GOOGLE_BOOKS_API_KEY_FILE` | empty |
5758
| Comic Vine | `COMICVINE_API` | `COMICVINE_API_FILE` | non-empty shared key |
5859
| Last.fm | `LASTFM_API_KEY` | none | empty |
5960
| Trakt | `TRAKT_API` | `TRAKT_API_FILE` | empty |
@@ -92,6 +93,7 @@ the later resolver and direct-read contract test must preserve.
9293
| `STEAM_API_KEY` | `integrations.imports.steam.SteamImporter.__init__` | Steam importer API key captured per importer instance |
9394
| `BGG_API_TOKEN` | `app.providers.bgg.search`, `._fetch_thumbnails`, `.boardgame`; `app.discover.provider_candidates._bgg_hot_candidates` | BGG bearer header for metadata and Discover |
9495
| `HARDCOVER_API` | `app.providers.hardcover._authorization_header` | normalized Hardcover authorization header used by provider calls |
96+
| `GOOGLE_BOOKS_API_KEY` | `app.providers.googlebooks.search`, `.book` | Google Books `key` request parameter for book search and volume metadata |
9597
| `COMICVINE_API` | `app.providers.comicvine.search`, `.comic`, `.get_volume_issues`, `.get_publisher_comics`, `.search_issues`, `.comic_issue`, `.issue`, `.person_profile`; `app.discover.provider_candidates._comicvine_volume_candidates`, `._comicvine_coming_soon_volume_candidates` | Comic Vine API parameter for metadata, people, issue, and Discover requests |
9698
| `LASTFM_API_KEY` | `integrations.lastfm_api._make_api_request`; `app.discover.provider_candidates._lastfm_top_tracks_candidates` | Last.fm integration calls and Discover top tracks; configured-state check is part of each symbol |
9799
| `TRAKT_API` | `app.providers.trakt.is_configured`, `._headers`; `app.discover.providers.trakt_adapter.TraktDiscoverAdapter._cache_request`; `integrations.views.trakt_oauth`; `integrations.imports.trakt.handle_oauth_callback`, `.get_username_from_oauth`, `.get_access_token`, `.TraktImporter._make_api_request`; `lists.imports.trakt._make_trakt_request`; `users.views.import_data`; `users.onboarding_views.onboarding_service_setup` | instance Trakt configured state, metadata/Discover headers, OAuth start/exchange/refresh, profile/list imports, and UI inference |
@@ -120,7 +122,7 @@ every possible dynamic provider call is statically enumerable.
120122

121123
| Context | Verified indirect consumers and behavior |
122124
|---|---|
123-
| Interactive routing | `src/app/providers/services.py` dispatches search and metadata work to TMDB, TVDB, MAL, IGDB, BGG, Hardcover, and Comic Vine. `src/app/services/metadata_resolution.py` filters TVDB availability. Verified callers include `src/app/search_views.py`, metadata/detail/people views, `src/api/views.py`, `src/lists/views_recommendations.py`, and `src/lists/views_add_reorder.py`. |
125+
| Interactive routing | `src/app/providers/services.py` dispatches search and metadata work to TMDB, TVDB, MAL, IGDB, BGG, Hardcover, Google Books, and Comic Vine. `src/app/services/metadata_resolution.py` filters TVDB and Google Books availability. Verified callers include `src/app/search_views.py`, metadata/detail/people views, `src/api/views.py`, `src/lists/views_recommendations.py`, and `src/lists/views_add_reorder.py`. |
124126
| Discover and statistics | `src/app/discover/provider_candidates.py` and the TMDB/Trakt adapters fetch credentialed rows. `src/app/statistics_views.py` calls `tvdb.enabled()` for its page contexts. |
125127
| TVDB background work | `src/app/tasks_genre.py` gates genre backfill on `tvdb.enabled()` and calls TVDB lookup/genre helpers. `src/app/tasks_metadata_cache.py` derives TVDB metadata cache keys. `src/app/tasks_tv_provider_migration.py` and `src/app/services/tv_provider_migration.py` gate and fetch TVDB migration data. |
126128
| Other background work | `src/app/tasks_trakt.py` calls the Trakt provider for popularity and episode ratings. `src/app/tasks_providers.py`, `src/app/tasks_episode.py`, and `src/app/tasks_metadata_cache.py` fetch through provider services. Calendar modules under `src/events/calendar/` use provider services and direct TMDB, TVDB, MAL, and Comic Vine modules. Last.fm tasks in `src/integrations/tasks/_lastfm.py` call `src/integrations/lastfm_api.py`. |

src/api/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def resolve_episode_coordinate_for_request(
198198
MediaTypes.ANIME.value: ["mal", "manual"],
199199
MediaTypes.MANGA.value: ["mal", "mangaupdates", "manual"],
200200
MediaTypes.GAME.value: ["igdb", "manual"],
201-
MediaTypes.BOOK.value: ["openlibrary", "hardcover", "manual"],
201+
MediaTypes.BOOK.value: ["openlibrary", "hardcover", "googlebooks", "manual"],
202202
MediaTypes.COMIC.value: ["comicvine", "manual"],
203203
MediaTypes.BOARDGAME.value: ["bgg", "manual"],
204204
}

src/api/tests/test_search.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
from django.urls import reverse
44

5+
from api.helpers import check_source_type
6+
57
from .base import FloppyApiTestCase
68
from .helpers import check_pagination_structure
79

810

911
class SearchTests(FloppyApiTestCase):
1012
"""Validate search endpoint contracts."""
1113

14+
def test_book_googlebooks_source_is_valid(self):
15+
"""Google Books is an accepted source for book API routes."""
16+
self.assertTrue(check_source_type("book", "googlebooks"))
17+
1218
def test_search_rejects_invalid_media_type(self):
1319
"""Search endpoint should reject unknown media types."""
1420
response = self.call_api(

src/app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@
171171
"date_key": "release_date",
172172
},
173173
MediaTypes.BOOK.value: {
174-
"sources": [Sources.HARDCOVER, Sources.OPENLIBRARY],
174+
"sources": [Sources.HARDCOVER, Sources.OPENLIBRARY, Sources.GOOGLEBOOKS],
175175
"default_source": Sources.HARDCOVER,
176176
"unicode_icon": "📖",
177177
"verb": ("read", "read"),

src/app/models/choices.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Sources(models.TextChoices):
1212
IMDB = "imdb", "IMDb"
1313
OPENLIBRARY = "openlibrary", "Open Library"
1414
HARDCOVER = "hardcover", "Hardcover"
15+
GOOGLEBOOKS = "googlebooks", "Google Books"
1516
COMICVINE = "comicvine", "Comic Vine"
1617
BGG = "bgg", "BoardGameGeek"
1718
MUSICBRAINZ = "musicbrainz", "MusicBrainz"

src/app/providers/googlebooks.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Google Books metadata provider."""
2+
3+
import re
4+
5+
import requests
6+
from bs4 import BeautifulSoup
7+
from django.conf import settings
8+
from django.core.cache import cache
9+
10+
from app import helpers
11+
from app.models import MediaTypes, Sources
12+
from app.providers import services
13+
14+
BASE_URL = "https://www.googleapis.com/books/v1/volumes"
15+
IMAGE_LINK_KEYS = (
16+
"extraLarge",
17+
"large",
18+
"medium",
19+
"small",
20+
"thumbnail",
21+
"smallThumbnail",
22+
)
23+
YEAR_RE = re.compile(r"\b(\d{4})\b")
24+
25+
26+
def enabled():
27+
"""Return whether the instance has a Google Books API key."""
28+
return bool(settings.GOOGLE_BOOKS_API_KEY)
29+
30+
31+
def handle_error(error):
32+
"""Handle Google Books API errors."""
33+
raise services.ProviderAPIError(Sources.GOOGLEBOOKS.value, error)
34+
35+
36+
def search(query, page, language=None):
37+
"""Search Google Books volumes."""
38+
language_key = language or "all"
39+
cache_key = (
40+
f"search_{Sources.GOOGLEBOOKS.value}_{MediaTypes.BOOK.value}_"
41+
f"{query}_{language_key}_{page}"
42+
)
43+
data = cache.get(cache_key)
44+
45+
if data is None:
46+
params = {
47+
"q": query,
48+
"startIndex": max(0, (page - 1) * settings.PER_PAGE),
49+
"maxResults": min(settings.PER_PAGE, 40),
50+
"printType": "books",
51+
"key": settings.GOOGLE_BOOKS_API_KEY,
52+
}
53+
if language:
54+
params["langRestrict"] = language
55+
56+
try:
57+
response = services.api_request(
58+
Sources.GOOGLEBOOKS.value,
59+
"GET",
60+
BASE_URL,
61+
params=params,
62+
)
63+
except requests.RequestException as error:
64+
handle_error(error)
65+
66+
results = [
67+
result
68+
for item in response.get("items") or []
69+
if (result := _normalize_search_result(item)) is not None
70+
]
71+
data = helpers.format_search_response(
72+
page,
73+
settings.PER_PAGE,
74+
response.get("totalItems") or 0,
75+
results,
76+
)
77+
cache.set(cache_key, data)
78+
79+
return data
80+
81+
82+
def book(media_id):
83+
"""Return normalized metadata for a Google Books volume."""
84+
cache_key = f"{Sources.GOOGLEBOOKS.value}_{MediaTypes.BOOK.value}_{media_id}"
85+
data = cache.get(cache_key)
86+
87+
if data is None:
88+
try:
89+
response = services.api_request(
90+
Sources.GOOGLEBOOKS.value,
91+
"GET",
92+
f"{BASE_URL}/{media_id}",
93+
params={"key": settings.GOOGLE_BOOKS_API_KEY},
94+
)
95+
except requests.RequestException as error:
96+
handle_error(error)
97+
98+
data = _normalize_book(response, media_id)
99+
cache.set(cache_key, data)
100+
101+
return data
102+
103+
104+
def _normalize_search_result(item):
105+
"""Convert one Google Books volume into a search result."""
106+
volume_info = item.get("volumeInfo") or {}
107+
media_id = item.get("id")
108+
title = volume_info.get("title")
109+
if not media_id or not title:
110+
return None
111+
112+
return {
113+
"media_id": media_id,
114+
"source": Sources.GOOGLEBOOKS.value,
115+
"media_type": MediaTypes.BOOK.value,
116+
"title": title,
117+
"image": _image_url(volume_info.get("imageLinks")),
118+
"year": _publication_year(volume_info.get("publishedDate")),
119+
}
120+
121+
122+
def _normalize_book(response, media_id):
123+
"""Convert a Google Books volume into Floppy's metadata shape."""
124+
volume_info = response.get("volumeInfo") or {}
125+
title = volume_info.get("title") or ""
126+
authors = [
127+
author
128+
for author in volume_info.get("authors") or []
129+
if isinstance(author, str) and author
130+
]
131+
average_rating = volume_info.get("averageRating")
132+
try:
133+
score = float(average_rating) * 2 if average_rating is not None else None
134+
except (TypeError, ValueError):
135+
score = None
136+
137+
published_date = volume_info.get("publishedDate")
138+
source_url = (
139+
volume_info.get("canonicalVolumeLink")
140+
or volume_info.get("infoLink")
141+
or f"https://books.google.com/books?id={media_id}"
142+
)
143+
language = volume_info.get("language")
144+
print_type = volume_info.get("printType")
145+
isbn = []
146+
for identifier in volume_info.get("industryIdentifiers") or []:
147+
if not isinstance(identifier, dict):
148+
continue
149+
if identifier.get("type") in {"ISBN_10", "ISBN_13"}:
150+
value = identifier.get("identifier")
151+
if value:
152+
isbn.append(value)
153+
154+
return {
155+
"media_id": media_id,
156+
"source": Sources.GOOGLEBOOKS.value,
157+
"source_url": source_url,
158+
"media_type": MediaTypes.BOOK.value,
159+
"title": title,
160+
"max_progress": volume_info.get("pageCount"),
161+
"image": _image_url(volume_info.get("imageLinks")),
162+
"synopsis": _description(volume_info.get("description")),
163+
"genres": volume_info.get("categories") or [],
164+
"score": score,
165+
"score_count": volume_info.get("ratingsCount") or 0,
166+
"details": {
167+
"format": print_type.title() if print_type else None,
168+
"number_of_pages": volume_info.get("pageCount"),
169+
"publish_date": published_date,
170+
"author": authors or None,
171+
"publisher": volume_info.get("publisher"),
172+
"isbn": isbn,
173+
"languages": [language] if language else [],
174+
},
175+
"authors_full": [],
176+
"related": {},
177+
}
178+
179+
180+
def _image_url(image_links):
181+
"""Choose the highest-resolution available cover image."""
182+
if not isinstance(image_links, dict):
183+
return settings.IMG_NONE
184+
185+
for key in IMAGE_LINK_KEYS:
186+
image = image_links.get(key)
187+
if image:
188+
return str(image).replace("http://", "https://", 1)
189+
return settings.IMG_NONE
190+
191+
192+
def _publication_year(date_value):
193+
"""Extract the first four-digit publication year."""
194+
match = YEAR_RE.search(str(date_value or ""))
195+
return int(match.group(1)) if match else None
196+
197+
198+
def _description(description):
199+
"""Strip markup from an optional Google Books description."""
200+
if not description:
201+
return "No synopsis available."
202+
text = BeautifulSoup(str(description), "html.parser").get_text(separator=" ")
203+
return " ".join(text.split()) or "No synopsis available."

src/app/providers/services.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from app.providers import (
2323
bgg,
2424
comicvine,
25+
googlebooks,
2526
hardcover,
2627
igdb,
2728
mal,
@@ -838,6 +839,8 @@ def tvdb_season_metadata(routed_media_type=MediaTypes.TV.value):
838839
MediaTypes.BOOK.value: lambda: (
839840
hardcover.book(media_id, edition_id=edition_id, user=user)
840841
if source == Sources.HARDCOVER.value
842+
else googlebooks.book(media_id)
843+
if source == Sources.GOOGLEBOOKS.value
841844
else _audiobookshelf_book(media_id)
842845
if source == Sources.AUDIOBOOKSHELF.value
843846
else _storyteller_book(media_id)
@@ -887,20 +890,34 @@ def _resolve_search_source(media_type, source=None):
887890
"""Return the effective search provider for a media type."""
888891
resolved = _normalize_source_value(source)
889892
if resolved:
890-
if resolved == Sources.TVDB.value and not tvdb.enabled():
893+
if (
894+
resolved == Sources.TVDB.value
895+
and not tvdb.enabled()
896+
) or (
897+
resolved == Sources.GOOGLEBOOKS.value
898+
and not googlebooks.enabled()
899+
):
891900
resolved = None
892901
else:
893902
return resolved
894903

895904
default_source = config.get_default_source_name(media_type)
896905
default_value = _normalize_source_value(default_source)
897-
if default_value != Sources.TVDB.value or tvdb.enabled():
906+
if default_value not in {Sources.TVDB.value, Sources.GOOGLEBOOKS.value} or (
907+
default_value == Sources.TVDB.value
908+
and tvdb.enabled()
909+
) or (
910+
default_value == Sources.GOOGLEBOOKS.value
911+
and googlebooks.enabled()
912+
):
898913
return default_value
899914

900915
for candidate in config.get_sources(media_type) or []:
901916
candidate_value = _normalize_source_value(candidate)
902917
if candidate_value == Sources.TVDB.value and not tvdb.enabled():
903918
continue
919+
if candidate_value == Sources.GOOGLEBOOKS.value and not googlebooks.enabled():
920+
continue
904921
if candidate_value:
905922
return candidate_value
906923

@@ -1204,7 +1221,7 @@ def search(
12041221
if page == 1:
12051222
id_result = search_by_id(media_type, query, source, user=user)
12061223

1207-
if media_type == MediaTypes.BOOK.value and source != Sources.OPENLIBRARY.value:
1224+
if media_type == MediaTypes.BOOK.value and source == Sources.HARDCOVER.value:
12081225
isbn_result = _resolve_hardcover_isbn_search(query, page, user=user)
12091226
if isbn_result is not None:
12101227
return isbn_result
@@ -1245,6 +1262,8 @@ def search(
12451262
MediaTypes.BOOK.value: lambda: (
12461263
openlibrary.search(query, page)
12471264
if source == Sources.OPENLIBRARY.value
1265+
else googlebooks.search(query, page, language=language)
1266+
if source == Sources.GOOGLEBOOKS.value
12481267
else hardcover.search(query, page, user=user)
12491268
),
12501269
MediaTypes.COMIC.value: lambda: comicvine.search(query, page),

src/app/services/metadata_resolution.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def provider_is_enabled(provider: str) -> bool:
6767
"""Return whether a provider is configured for live use."""
6868
if provider == Sources.TVDB.value:
6969
return bool(settings.TVDB_API_KEY)
70+
if provider == Sources.GOOGLEBOOKS.value:
71+
return bool(settings.GOOGLE_BOOKS_API_KEY)
7072
return True
7173

7274

0 commit comments

Comments
 (0)