-
Notifications
You must be signed in to change notification settings - Fork 4
/
opds.py
294 lines (250 loc) · 9.93 KB
/
opds.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
from __future__ import annotations
import json
import flask
from sqlalchemy.engine.row import Row
from sqlalchemy.orm import Query
from authentication_document import AuthenticationDocument
from config import Configuration
from model import ConfigurationSetting, Hyperlink, LibraryType, Validation
from util.http import NormalizedMediaType
class Annotator:
def annotate_catalog(self, catalog, live=True):
pass
class OPDSCatalog:
"""Represents an OPDS 2 Catalog Document.
https://github.com/opds-community/opds-revision/blob/master/opds-2.0.md
This document may stand on its own, or be contained within another
OPDS 2 Catalog Document in a collection with the "catalogs" role.
Within the "catalogs" role, metadata and the navigation collection role
have the same semantics as in the overall OPDS 2 Catalog spec.
"""
TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ%z"
OPDS_TYPE = "application/opds+json"
OPDS_1_TYPE = "application/atom+xml;profile=opds-catalog;kind=acquisition"
CATALOG_REL = "http://opds-spec.org/catalog"
THUMBNAIL_REL = "http://opds-spec.org/image/thumbnail"
ELIGIBILITY_REL = "http://librarysimplified.org/rel/registry/eligibility"
FOCUS_REL = "http://librarysimplified.org/rel/registry/focus"
CACHE_TIME = 3600 * 12
_NORMALIZED_OPDS2_TYPE = NormalizedMediaType(OPDS_TYPE)
_NORMALIZED_OPDS1_TYPE = NormalizedMediaType(OPDS_1_TYPE)
@classmethod
def _strftime(cls, date):
"""
Format a date the way Atom likes it (RFC3339?)
"""
return date.strftime(cls.TIME_FORMAT)
@classmethod
def add_link_to_catalog(cls, catalog, children=None, **kwargs):
link = dict(**kwargs)
catalog.setdefault("links", []).append(link)
@classmethod
def add_image_to_catalog(cls, catalog, children=None, **kwargs):
image = dict(**kwargs)
catalog.setdefault("images", []).append(image)
def __init__(
self, _db, title, url, libraries, annotator=None, live=True, url_for=None
):
"""Turn a list of libraries into a catalog."""
if not annotator:
annotator = Annotator()
# To save bandwidth, omit logos from large feeds. What 'large'
# means is customizable.
#
# To save time, omit service area information from large feeds
# which we know won't use it.
include_logos = include_service_areas = not (
self._feed_is_large(_db, libraries)
)
self.catalog = dict(metadata=dict(title=title), catalogs=[])
self.add_link_to_catalog(
self.catalog, rel="self", href=url, type=self.OPDS_TYPE
)
web_client_uri_template = ConfigurationSetting.sitewide(
_db, Configuration.WEB_CLIENT_URL
).value
for library in libraries:
if not isinstance(library, Row):
library = (library,)
self.catalog["catalogs"].append(
self.library_catalog(
*library,
url_for=url_for,
include_logo=include_logos,
web_client_uri_template=web_client_uri_template,
include_service_area=include_service_areas,
)
)
annotator.annotate_catalog(self, live=live)
@classmethod
def is_opds1_type(cls, media_type: str | None) -> bool:
return cls._NORMALIZED_OPDS1_TYPE.min_match(media_type)
@classmethod
def is_opds2_type(cls, media_type: str | None) -> bool:
return cls._NORMALIZED_OPDS2_TYPE.min_match(media_type)
@classmethod
def is_opds_type(cls, media_type: str | None) -> bool:
return cls.is_opds1_type(media_type) or cls.is_opds2_type(media_type)
@classmethod
def _feed_is_large(cls, _db, libraries):
"""Determine whether a prospective feed is 'large' per a sitewide setting.
:param _db: A database session
:param libraries: A list of libraries (or anything else that might be
going into a feed).
"""
large_feed_size = ConfigurationSetting.sitewide(
_db, Configuration.LARGE_FEED_SIZE
).int_value
if large_feed_size is None:
# No limit
return False
if isinstance(libraries, Query):
# This is a SQLAlchemy query.
size = libraries.count()
else:
# This is something like a normal Python list.
size = len(libraries)
return size >= large_feed_size
@classmethod
def library_catalog(
cls,
library,
distance=None,
include_private_information=False,
include_logo=True,
url_for=None,
web_client_uri_template=None,
include_service_area=False,
):
"""Create an OPDS catalog for a library.
:param distance: The distance, in meters, from the client's
current location (if known) to the edge of this library's
service area.
:param include_private_information: If this is True, the
consumer of this OPDS catalog is expected to be the library
whose catalog it is. Private information such as the point of
contact for integration problems will be included, where it
normally wouldn't be.
:param include_service_area: If this is True, the
consumer of this OPDS catalog will be using information about
the library's service area. TODO: This can be removed
once we stop using the endpoints that just give a huge
list of libraries.
"""
url_for = url_for or flask.url_for
modified = cls._strftime(library.timestamp)
metadata = dict(
id=library.internal_urn,
title=library.name,
modified=modified,
updated=modified, # For backwards compatibility with earlier
# clients.
)
if distance is not None:
# 'distance' for backwards compatibility.
value = "%d km." % (distance / 1000)
for key in "schema:distance", "distance":
metadata[key] = value
if library.description:
metadata["description"] = library.description
if include_service_area:
service_area_name = library.service_area_name
if service_area_name is not None:
metadata["schema:areaServed"] = service_area_name
subjects = []
for code in library.types:
subjects.append(
dict(
code=code,
name=LibraryType.NAME_FOR_CODE[code],
scheme=LibraryType.SCHEME_URI,
)
)
if subjects:
metadata["subject"] = subjects
catalog = dict(metadata=metadata)
if library.opds_url:
# TODO: Keep track of whether each library uses OPDS 1 or 2?
cls.add_link_to_catalog(
catalog,
rel=cls.CATALOG_REL,
href=library.opds_url,
type=cls.OPDS_1_TYPE,
)
if library.authentication_url:
cls.add_link_to_catalog(
catalog,
href=library.authentication_url,
type=AuthenticationDocument.MEDIA_TYPE,
)
if library.web_url:
cls.add_link_to_catalog(
catalog, rel="alternate", href=library.web_url, type="text/html"
)
if library.logo_url:
cls.add_image_to_catalog(
catalog, rel=cls.THUMBNAIL_REL, href=library.logo_url, type="image/png"
)
# Add links that allow clients to discover the library's
# focus and eligibility area.
for rel, route in (
(cls.ELIGIBILITY_REL, "library_eligibility"),
(cls.FOCUS_REL, "library_focus"),
):
url = url_for(route, uuid=library.internal_urn, _external=True)
cls.add_link_to_catalog(
catalog, rel=rel, href=url, type="application/geo+json"
)
for hyperlink in library.hyperlinks:
if (
not include_private_information
and hyperlink.rel in Hyperlink.PRIVATE_RELS
):
continue
args = cls._hyperlink_args(hyperlink)
if not args:
# Not enough information to create a link.
continue
cls.add_link_to_catalog(catalog, **args)
# Add a link for the registry's web client, if it has one.
if web_client_uri_template:
web_client_url = web_client_uri_template.replace(
"{uuid}", library.internal_urn
)
cls.add_link_to_catalog(
catalog, href=web_client_url, rel="self", type="text/html"
)
return catalog
@classmethod
def _hyperlink_args(cls, hyperlink):
"""Turn a Hyperlink into a dictionary of arguments that can
be turned into an OPDS 2 link.
"""
if not hyperlink:
return None
resource = hyperlink.resource
if not resource:
return None
href = resource.href
if not href:
return None
args = dict(rel=hyperlink.rel, href=href)
# If there was ever an attempt to validate this Hyperlink,
# explain the status of that attempt.
properties = {}
validation = resource.validation
if validation:
if validation.success:
status = Validation.CONFIRMED
elif validation.active:
status = Validation.IN_PROGRESS
else:
status = Validation.INACTIVE
properties[Validation.STATUS_PROPERTY] = status
if properties:
args["properties"] = properties
return args
def __str__(self):
if self.catalog is None:
return None
return json.dumps(self.catalog)