ebk 0.4.4__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.
- ebk/__init__.py +35 -0
- ebk/ai/__init__.py +23 -0
- ebk/ai/knowledge_graph.py +450 -0
- ebk/ai/llm_providers/__init__.py +26 -0
- ebk/ai/llm_providers/anthropic.py +209 -0
- ebk/ai/llm_providers/base.py +295 -0
- ebk/ai/llm_providers/gemini.py +285 -0
- ebk/ai/llm_providers/ollama.py +294 -0
- ebk/ai/metadata_enrichment.py +394 -0
- ebk/ai/question_generator.py +328 -0
- ebk/ai/reading_companion.py +224 -0
- ebk/ai/semantic_search.py +433 -0
- ebk/ai/text_extractor.py +393 -0
- ebk/calibre_import.py +66 -0
- ebk/cli.py +6433 -0
- ebk/config.py +230 -0
- ebk/db/__init__.py +37 -0
- ebk/db/migrations.py +507 -0
- ebk/db/models.py +725 -0
- ebk/db/session.py +144 -0
- ebk/decorators.py +1 -0
- ebk/exports/__init__.py +0 -0
- ebk/exports/base_exporter.py +218 -0
- ebk/exports/echo_export.py +279 -0
- ebk/exports/html_library.py +1743 -0
- ebk/exports/html_utils.py +87 -0
- ebk/exports/hugo.py +59 -0
- ebk/exports/jinja_export.py +286 -0
- ebk/exports/multi_facet_export.py +159 -0
- ebk/exports/opds_export.py +232 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/exports/zip.py +25 -0
- ebk/extract_metadata.py +341 -0
- ebk/ident.py +89 -0
- ebk/library_db.py +1440 -0
- ebk/opds.py +748 -0
- ebk/plugins/__init__.py +42 -0
- ebk/plugins/base.py +502 -0
- ebk/plugins/hooks.py +442 -0
- ebk/plugins/registry.py +499 -0
- ebk/repl/__init__.py +9 -0
- ebk/repl/find.py +126 -0
- ebk/repl/grep.py +173 -0
- ebk/repl/shell.py +1677 -0
- ebk/repl/text_utils.py +320 -0
- ebk/search_parser.py +413 -0
- ebk/server.py +3608 -0
- ebk/services/__init__.py +28 -0
- ebk/services/annotation_extraction.py +351 -0
- ebk/services/annotation_service.py +380 -0
- ebk/services/export_service.py +577 -0
- ebk/services/import_service.py +447 -0
- ebk/services/personal_metadata_service.py +347 -0
- ebk/services/queue_service.py +253 -0
- ebk/services/tag_service.py +281 -0
- ebk/services/text_extraction.py +317 -0
- ebk/services/view_service.py +12 -0
- ebk/similarity/__init__.py +77 -0
- ebk/similarity/base.py +154 -0
- ebk/similarity/core.py +471 -0
- ebk/similarity/extractors.py +168 -0
- ebk/similarity/metrics.py +376 -0
- ebk/skills/SKILL.md +182 -0
- ebk/skills/__init__.py +1 -0
- ebk/vfs/__init__.py +101 -0
- ebk/vfs/base.py +298 -0
- ebk/vfs/library_vfs.py +122 -0
- ebk/vfs/nodes/__init__.py +54 -0
- ebk/vfs/nodes/authors.py +196 -0
- ebk/vfs/nodes/books.py +480 -0
- ebk/vfs/nodes/files.py +155 -0
- ebk/vfs/nodes/metadata.py +385 -0
- ebk/vfs/nodes/root.py +100 -0
- ebk/vfs/nodes/similar.py +165 -0
- ebk/vfs/nodes/subjects.py +184 -0
- ebk/vfs/nodes/tags.py +371 -0
- ebk/vfs/resolver.py +228 -0
- ebk/vfs_router.py +275 -0
- ebk/views/__init__.py +32 -0
- ebk/views/dsl.py +668 -0
- ebk/views/service.py +619 -0
- ebk-0.4.4.dist-info/METADATA +755 -0
- ebk-0.4.4.dist-info/RECORD +87 -0
- ebk-0.4.4.dist-info/WHEEL +5 -0
- ebk-0.4.4.dist-info/entry_points.txt +2 -0
- ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
- ebk-0.4.4.dist-info/top_level.txt +1 -0
ebk/opds.py
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OPDS (Open Publication Distribution System) catalog server.
|
|
3
|
+
|
|
4
|
+
Provides an OPDS 1.2 compatible catalog feed for e-reader apps like:
|
|
5
|
+
- Foliate (Linux)
|
|
6
|
+
- KOReader
|
|
7
|
+
- Moon+ Reader (Android)
|
|
8
|
+
- Marvin (iOS)
|
|
9
|
+
- Thorium Reader
|
|
10
|
+
|
|
11
|
+
OPDS Spec: https://specs.opds.io/opds-1.2
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
20
|
+
from fastapi.responses import Response, FileResponse
|
|
21
|
+
|
|
22
|
+
from .library_db import Library
|
|
23
|
+
from .db.models import Book
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/opds", tags=["OPDS"])
|
|
27
|
+
|
|
28
|
+
# MIME types
|
|
29
|
+
OPDS_MIME = "application/atom+xml;profile=opds-catalog;kind=navigation"
|
|
30
|
+
OPDS_ACQUISITION_MIME = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
|
31
|
+
OPENSEARCH_MIME = "application/opensearchdescription+xml"
|
|
32
|
+
|
|
33
|
+
# File format MIME types
|
|
34
|
+
FORMAT_MIMES = {
|
|
35
|
+
"pdf": "application/pdf",
|
|
36
|
+
"epub": "application/epub+zip",
|
|
37
|
+
"mobi": "application/x-mobipocket-ebook",
|
|
38
|
+
"azw": "application/vnd.amazon.ebook",
|
|
39
|
+
"azw3": "application/vnd.amazon.ebook",
|
|
40
|
+
"txt": "text/plain",
|
|
41
|
+
"html": "text/html",
|
|
42
|
+
"htm": "text/html",
|
|
43
|
+
"djvu": "image/vnd.djvu",
|
|
44
|
+
"cbz": "application/vnd.comicbook+zip",
|
|
45
|
+
"cbr": "application/vnd.comicbook-rar",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_mime_type(format: str) -> str:
|
|
50
|
+
"""Get MIME type for ebook format."""
|
|
51
|
+
return FORMAT_MIMES.get(format.lower(), "application/octet-stream")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def escape_xml(text: str) -> str:
|
|
55
|
+
"""Escape XML special characters."""
|
|
56
|
+
if not text:
|
|
57
|
+
return ""
|
|
58
|
+
return (text
|
|
59
|
+
.replace("&", "&")
|
|
60
|
+
.replace("<", "<")
|
|
61
|
+
.replace(">", ">")
|
|
62
|
+
.replace('"', """)
|
|
63
|
+
.replace("'", "'"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_datetime(dt: Optional[datetime] = None) -> str:
|
|
67
|
+
"""Format datetime for Atom feed."""
|
|
68
|
+
if dt is None:
|
|
69
|
+
dt = datetime.now(timezone.utc)
|
|
70
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_entry(book: Book, base_url: str) -> str:
|
|
74
|
+
"""Build an OPDS entry for a book."""
|
|
75
|
+
book_id = book.id
|
|
76
|
+
title = escape_xml(book.title or "Untitled")
|
|
77
|
+
|
|
78
|
+
# Authors
|
|
79
|
+
authors_xml = ""
|
|
80
|
+
if book.authors:
|
|
81
|
+
for author in book.authors:
|
|
82
|
+
authors_xml += f"""
|
|
83
|
+
<author>
|
|
84
|
+
<name>{escape_xml(author.name)}</name>
|
|
85
|
+
</author>"""
|
|
86
|
+
|
|
87
|
+
# Summary/description
|
|
88
|
+
summary = ""
|
|
89
|
+
if book.description:
|
|
90
|
+
summary = f"<summary>{escape_xml(book.description[:500])}</summary>"
|
|
91
|
+
|
|
92
|
+
# Categories (subjects)
|
|
93
|
+
categories = ""
|
|
94
|
+
if book.subjects:
|
|
95
|
+
for subj in book.subjects:
|
|
96
|
+
categories += f'<category term="{escape_xml(subj.name)}" label="{escape_xml(subj.name)}"/>'
|
|
97
|
+
|
|
98
|
+
# Language
|
|
99
|
+
language = f"<dc:language>{escape_xml(book.language)}</dc:language>" if book.language else ""
|
|
100
|
+
|
|
101
|
+
# Publisher
|
|
102
|
+
publisher = f"<dc:publisher>{escape_xml(book.publisher)}</dc:publisher>" if book.publisher else ""
|
|
103
|
+
|
|
104
|
+
# Publication date
|
|
105
|
+
pub_date = ""
|
|
106
|
+
if book.publication_date:
|
|
107
|
+
pub_date = f"<dc:date>{escape_xml(str(book.publication_date))}</dc:date>"
|
|
108
|
+
|
|
109
|
+
# Cover image
|
|
110
|
+
cover_link = ""
|
|
111
|
+
if book.covers:
|
|
112
|
+
cover = book.covers[0]
|
|
113
|
+
if cover.path:
|
|
114
|
+
# Use full cover for both (thumbnails generated on-the-fly if needed)
|
|
115
|
+
cover_link = f'<link rel="http://opds-spec.org/image/thumbnail" href="{base_url}/opds/cover/{book_id}" type="image/jpeg"/>'
|
|
116
|
+
cover_link += f'\n <link rel="http://opds-spec.org/image" href="{base_url}/opds/cover/{book_id}" type="image/jpeg"/>'
|
|
117
|
+
|
|
118
|
+
# Acquisition links (download links for each format)
|
|
119
|
+
acquisition_links = ""
|
|
120
|
+
if book.files:
|
|
121
|
+
for file in book.files:
|
|
122
|
+
mime = get_mime_type(file.format)
|
|
123
|
+
size_bytes = file.size_bytes or 0
|
|
124
|
+
size_kb = size_bytes // 1024 if size_bytes else 0
|
|
125
|
+
acquisition_links += f"""
|
|
126
|
+
<link rel="http://opds-spec.org/acquisition"
|
|
127
|
+
href="{base_url}/opds/download/{book_id}/{file.format}"
|
|
128
|
+
type="{mime}"
|
|
129
|
+
length="{size_bytes}"
|
|
130
|
+
title="{file.format.upper()} ({size_kb} KB)"/>"""
|
|
131
|
+
|
|
132
|
+
# Updated timestamp
|
|
133
|
+
updated = format_datetime(book.updated_at if hasattr(book, 'updated_at') else None)
|
|
134
|
+
|
|
135
|
+
return f"""
|
|
136
|
+
<entry>
|
|
137
|
+
<id>urn:ebk:book:{book_id}</id>
|
|
138
|
+
<title>{title}</title>
|
|
139
|
+
<updated>{updated}</updated>{authors_xml}
|
|
140
|
+
{summary}
|
|
141
|
+
{categories}
|
|
142
|
+
{language}
|
|
143
|
+
{publisher}
|
|
144
|
+
{pub_date}
|
|
145
|
+
{cover_link}
|
|
146
|
+
{acquisition_links}
|
|
147
|
+
<link rel="alternate" href="{base_url}/opds/book/{book_id}" type="{OPDS_ACQUISITION_MIME}"/>
|
|
148
|
+
</entry>"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_feed(
|
|
152
|
+
id: str,
|
|
153
|
+
title: str,
|
|
154
|
+
entries: str,
|
|
155
|
+
base_url: str,
|
|
156
|
+
links: str = "",
|
|
157
|
+
subtitle: str = "",
|
|
158
|
+
total_results: Optional[int] = None,
|
|
159
|
+
start_index: int = 1,
|
|
160
|
+
items_per_page: int = 50,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Build an OPDS Atom feed."""
|
|
163
|
+
updated = format_datetime()
|
|
164
|
+
|
|
165
|
+
# Pagination info for OpenSearch
|
|
166
|
+
pagination = ""
|
|
167
|
+
if total_results is not None:
|
|
168
|
+
pagination = f"""
|
|
169
|
+
<opensearch:totalResults>{total_results}</opensearch:totalResults>
|
|
170
|
+
<opensearch:startIndex>{start_index}</opensearch:startIndex>
|
|
171
|
+
<opensearch:itemsPerPage>{items_per_page}</opensearch:itemsPerPage>"""
|
|
172
|
+
|
|
173
|
+
subtitle_xml = f"<subtitle>{escape_xml(subtitle)}</subtitle>" if subtitle else ""
|
|
174
|
+
|
|
175
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
176
|
+
<feed xmlns="http://www.w3.org/2005/Atom"
|
|
177
|
+
xmlns:dc="http://purl.org/dc/terms/"
|
|
178
|
+
xmlns:opds="http://opds-spec.org/2010/catalog"
|
|
179
|
+
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
|
180
|
+
<id>{id}</id>
|
|
181
|
+
<title>{escape_xml(title)}</title>
|
|
182
|
+
{subtitle_xml}
|
|
183
|
+
<updated>{updated}</updated>
|
|
184
|
+
<icon>{base_url}/favicon.ico</icon>
|
|
185
|
+
|
|
186
|
+
<link rel="self" href="{base_url}/opds" type="{OPDS_MIME}"/>
|
|
187
|
+
<link rel="start" href="{base_url}/opds" type="{OPDS_MIME}"/>
|
|
188
|
+
<link rel="search" href="{base_url}/opds/opensearch.xml" type="{OPENSEARCH_MIME}"/>
|
|
189
|
+
{links}
|
|
190
|
+
{pagination}
|
|
191
|
+
{entries}
|
|
192
|
+
</feed>"""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Global library reference (set by server.py)
|
|
196
|
+
_library: Optional[Library] = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_library(lib: Library):
|
|
200
|
+
"""Set the library instance for OPDS routes."""
|
|
201
|
+
global _library
|
|
202
|
+
_library = lib
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_library() -> Library:
|
|
206
|
+
"""Get the current library instance."""
|
|
207
|
+
if _library is None:
|
|
208
|
+
raise HTTPException(status_code=500, detail="Library not initialized")
|
|
209
|
+
return _library
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_base_url(request: Request) -> str:
|
|
213
|
+
"""Get base URL from request."""
|
|
214
|
+
return str(request.base_url).rstrip("/")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@router.get("/", response_class=Response)
|
|
218
|
+
async def opds_root(request: Request):
|
|
219
|
+
"""
|
|
220
|
+
OPDS root catalog - navigation feed with links to browse the library.
|
|
221
|
+
"""
|
|
222
|
+
base_url = get_base_url(request)
|
|
223
|
+
lib = get_library()
|
|
224
|
+
stats = lib.stats()
|
|
225
|
+
|
|
226
|
+
entries = f"""
|
|
227
|
+
<entry>
|
|
228
|
+
<id>urn:ebk:all</id>
|
|
229
|
+
<title>All Books</title>
|
|
230
|
+
<content type="text">Browse all {stats['total_books']} books in the library</content>
|
|
231
|
+
<link rel="subsection" href="{base_url}/opds/all" type="{OPDS_ACQUISITION_MIME}"/>
|
|
232
|
+
<updated>{format_datetime()}</updated>
|
|
233
|
+
</entry>
|
|
234
|
+
|
|
235
|
+
<entry>
|
|
236
|
+
<id>urn:ebk:recent</id>
|
|
237
|
+
<title>Recently Added</title>
|
|
238
|
+
<content type="text">Most recently added books</content>
|
|
239
|
+
<link rel="subsection" href="{base_url}/opds/recent" type="{OPDS_ACQUISITION_MIME}"/>
|
|
240
|
+
<updated>{format_datetime()}</updated>
|
|
241
|
+
</entry>
|
|
242
|
+
|
|
243
|
+
<entry>
|
|
244
|
+
<id>urn:ebk:authors</id>
|
|
245
|
+
<title>By Author</title>
|
|
246
|
+
<content type="text">Browse {stats['total_authors']} authors</content>
|
|
247
|
+
<link rel="subsection" href="{base_url}/opds/authors" type="{OPDS_MIME}"/>
|
|
248
|
+
<updated>{format_datetime()}</updated>
|
|
249
|
+
</entry>
|
|
250
|
+
|
|
251
|
+
<entry>
|
|
252
|
+
<id>urn:ebk:subjects</id>
|
|
253
|
+
<title>By Subject</title>
|
|
254
|
+
<content type="text">Browse {stats['total_subjects']} subjects</content>
|
|
255
|
+
<link rel="subsection" href="{base_url}/opds/subjects" type="{OPDS_MIME}"/>
|
|
256
|
+
<updated>{format_datetime()}</updated>
|
|
257
|
+
</entry>
|
|
258
|
+
|
|
259
|
+
<entry>
|
|
260
|
+
<id>urn:ebk:languages</id>
|
|
261
|
+
<title>By Language</title>
|
|
262
|
+
<content type="text">Browse by language</content>
|
|
263
|
+
<link rel="subsection" href="{base_url}/opds/languages" type="{OPDS_MIME}"/>
|
|
264
|
+
<updated>{format_datetime()}</updated>
|
|
265
|
+
</entry>"""
|
|
266
|
+
|
|
267
|
+
feed = build_feed(
|
|
268
|
+
id="urn:ebk:root",
|
|
269
|
+
title="ebk Library",
|
|
270
|
+
subtitle=f"{stats['total_books']} books",
|
|
271
|
+
entries=entries,
|
|
272
|
+
base_url=base_url,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return Response(content=feed, media_type=OPDS_MIME)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@router.get("/opensearch.xml", response_class=Response)
|
|
279
|
+
async def opensearch_description(request: Request):
|
|
280
|
+
"""OpenSearch description document for search integration."""
|
|
281
|
+
base_url = get_base_url(request)
|
|
282
|
+
|
|
283
|
+
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
284
|
+
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
|
285
|
+
<ShortName>ebk Library</ShortName>
|
|
286
|
+
<Description>Search the ebk ebook library</Description>
|
|
287
|
+
<Url type="{OPDS_ACQUISITION_MIME}"
|
|
288
|
+
template="{base_url}/opds/search?q={{searchTerms}}&page={{startPage?}}&limit={{count?}}"/>
|
|
289
|
+
<InputEncoding>UTF-8</InputEncoding>
|
|
290
|
+
<OutputEncoding>UTF-8</OutputEncoding>
|
|
291
|
+
</OpenSearchDescription>"""
|
|
292
|
+
|
|
293
|
+
return Response(content=xml, media_type=OPENSEARCH_MIME)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@router.get("/all", response_class=Response)
|
|
297
|
+
async def opds_all_books(
|
|
298
|
+
request: Request,
|
|
299
|
+
page: int = Query(1, ge=1),
|
|
300
|
+
limit: int = Query(50, ge=1, le=100),
|
|
301
|
+
):
|
|
302
|
+
"""All books - acquisition feed with pagination."""
|
|
303
|
+
base_url = get_base_url(request)
|
|
304
|
+
lib = get_library()
|
|
305
|
+
|
|
306
|
+
offset = (page - 1) * limit
|
|
307
|
+
total = lib.query().count()
|
|
308
|
+
books = lib.query().order_by('title').limit(limit).offset(offset).all()
|
|
309
|
+
|
|
310
|
+
entries = "".join(build_entry(book, base_url) for book in books)
|
|
311
|
+
|
|
312
|
+
# Pagination links
|
|
313
|
+
links = ""
|
|
314
|
+
if page > 1:
|
|
315
|
+
links += f'<link rel="previous" href="{base_url}/opds/all?page={page-1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
316
|
+
if offset + len(books) < total:
|
|
317
|
+
links += f'<link rel="next" href="{base_url}/opds/all?page={page+1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
318
|
+
|
|
319
|
+
feed = build_feed(
|
|
320
|
+
id="urn:ebk:all",
|
|
321
|
+
title="All Books",
|
|
322
|
+
entries=entries,
|
|
323
|
+
base_url=base_url,
|
|
324
|
+
links=links,
|
|
325
|
+
total_results=total,
|
|
326
|
+
start_index=offset + 1,
|
|
327
|
+
items_per_page=limit,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@router.get("/recent", response_class=Response)
|
|
334
|
+
async def opds_recent(
|
|
335
|
+
request: Request,
|
|
336
|
+
limit: int = Query(50, ge=1, le=100),
|
|
337
|
+
):
|
|
338
|
+
"""Recently added books."""
|
|
339
|
+
base_url = get_base_url(request)
|
|
340
|
+
lib = get_library()
|
|
341
|
+
|
|
342
|
+
books = lib.query().order_by('created_at', desc=True).limit(limit).all()
|
|
343
|
+
entries = "".join(build_entry(book, base_url) for book in books)
|
|
344
|
+
|
|
345
|
+
feed = build_feed(
|
|
346
|
+
id="urn:ebk:recent",
|
|
347
|
+
title="Recently Added",
|
|
348
|
+
entries=entries,
|
|
349
|
+
base_url=base_url,
|
|
350
|
+
total_results=len(books),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@router.get("/search", response_class=Response)
|
|
357
|
+
async def opds_search(
|
|
358
|
+
request: Request,
|
|
359
|
+
q: str = Query(..., min_length=1),
|
|
360
|
+
page: int = Query(1, ge=1),
|
|
361
|
+
limit: int = Query(50, ge=1, le=100),
|
|
362
|
+
):
|
|
363
|
+
"""Search books - returns acquisition feed."""
|
|
364
|
+
base_url = get_base_url(request)
|
|
365
|
+
lib = get_library()
|
|
366
|
+
|
|
367
|
+
offset = (page - 1) * limit
|
|
368
|
+
books = lib.search(q, limit=limit, offset=offset)
|
|
369
|
+
|
|
370
|
+
entries = "".join(build_entry(book, base_url) for book in books)
|
|
371
|
+
|
|
372
|
+
# Pagination links
|
|
373
|
+
links = ""
|
|
374
|
+
if page > 1:
|
|
375
|
+
links += f'<link rel="previous" href="{base_url}/opds/search?q={quote(q)}&page={page-1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
376
|
+
if len(books) == limit: # Might be more
|
|
377
|
+
links += f'<link rel="next" href="{base_url}/opds/search?q={quote(q)}&page={page+1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
378
|
+
|
|
379
|
+
feed = build_feed(
|
|
380
|
+
id=f"urn:ebk:search:{quote(q)}",
|
|
381
|
+
title=f"Search: {q}",
|
|
382
|
+
entries=entries,
|
|
383
|
+
base_url=base_url,
|
|
384
|
+
links=links,
|
|
385
|
+
start_index=offset + 1,
|
|
386
|
+
items_per_page=limit,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@router.get("/authors", response_class=Response)
|
|
393
|
+
async def opds_authors(
|
|
394
|
+
request: Request,
|
|
395
|
+
page: int = Query(1, ge=1),
|
|
396
|
+
limit: int = Query(50, ge=1, le=100),
|
|
397
|
+
):
|
|
398
|
+
"""List all authors - navigation feed."""
|
|
399
|
+
base_url = get_base_url(request)
|
|
400
|
+
lib = get_library()
|
|
401
|
+
|
|
402
|
+
from .db.models import Author
|
|
403
|
+
offset = (page - 1) * limit
|
|
404
|
+
|
|
405
|
+
authors = lib.session.query(Author).order_by(Author.sort_name).offset(offset).limit(limit).all()
|
|
406
|
+
total = lib.session.query(Author).count()
|
|
407
|
+
|
|
408
|
+
entries = ""
|
|
409
|
+
for author in authors:
|
|
410
|
+
book_count = len(author.books)
|
|
411
|
+
entries += f"""
|
|
412
|
+
<entry>
|
|
413
|
+
<id>urn:ebk:author:{author.id}</id>
|
|
414
|
+
<title>{escape_xml(author.name)}</title>
|
|
415
|
+
<content type="text">{book_count} books</content>
|
|
416
|
+
<link rel="subsection" href="{base_url}/opds/author/{author.id}" type="{OPDS_ACQUISITION_MIME}"/>
|
|
417
|
+
<updated>{format_datetime()}</updated>
|
|
418
|
+
</entry>"""
|
|
419
|
+
|
|
420
|
+
# Pagination links
|
|
421
|
+
links = ""
|
|
422
|
+
if page > 1:
|
|
423
|
+
links += f'<link rel="previous" href="{base_url}/opds/authors?page={page-1}&limit={limit}" type="{OPDS_MIME}"/>'
|
|
424
|
+
if offset + len(authors) < total:
|
|
425
|
+
links += f'<link rel="next" href="{base_url}/opds/authors?page={page+1}&limit={limit}" type="{OPDS_MIME}"/>'
|
|
426
|
+
|
|
427
|
+
feed = build_feed(
|
|
428
|
+
id="urn:ebk:authors",
|
|
429
|
+
title="Authors",
|
|
430
|
+
entries=entries,
|
|
431
|
+
base_url=base_url,
|
|
432
|
+
links=links,
|
|
433
|
+
total_results=total,
|
|
434
|
+
start_index=offset + 1,
|
|
435
|
+
items_per_page=limit,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return Response(content=feed, media_type=OPDS_MIME)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@router.get("/author/{author_id}", response_class=Response)
|
|
442
|
+
async def opds_author_books(
|
|
443
|
+
request: Request,
|
|
444
|
+
author_id: int,
|
|
445
|
+
page: int = Query(1, ge=1),
|
|
446
|
+
limit: int = Query(50, ge=1, le=100),
|
|
447
|
+
):
|
|
448
|
+
"""Books by a specific author."""
|
|
449
|
+
base_url = get_base_url(request)
|
|
450
|
+
lib = get_library()
|
|
451
|
+
|
|
452
|
+
from .db.models import Author
|
|
453
|
+
author = lib.session.query(Author).filter(Author.id == author_id).first()
|
|
454
|
+
|
|
455
|
+
if not author:
|
|
456
|
+
raise HTTPException(status_code=404, detail="Author not found")
|
|
457
|
+
|
|
458
|
+
offset = (page - 1) * limit
|
|
459
|
+
books = author.books[offset:offset + limit]
|
|
460
|
+
total = len(author.books)
|
|
461
|
+
|
|
462
|
+
entries = "".join(build_entry(book, base_url) for book in books)
|
|
463
|
+
|
|
464
|
+
# Pagination links
|
|
465
|
+
links = ""
|
|
466
|
+
if page > 1:
|
|
467
|
+
links += f'<link rel="previous" href="{base_url}/opds/author/{author_id}?page={page-1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
468
|
+
if offset + len(books) < total:
|
|
469
|
+
links += f'<link rel="next" href="{base_url}/opds/author/{author_id}?page={page+1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
470
|
+
|
|
471
|
+
feed = build_feed(
|
|
472
|
+
id=f"urn:ebk:author:{author_id}",
|
|
473
|
+
title=f"Books by {author.name}",
|
|
474
|
+
entries=entries,
|
|
475
|
+
base_url=base_url,
|
|
476
|
+
links=links,
|
|
477
|
+
total_results=total,
|
|
478
|
+
start_index=offset + 1,
|
|
479
|
+
items_per_page=limit,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@router.get("/subjects", response_class=Response)
|
|
486
|
+
async def opds_subjects(
|
|
487
|
+
request: Request,
|
|
488
|
+
page: int = Query(1, ge=1),
|
|
489
|
+
limit: int = Query(50, ge=1, le=100),
|
|
490
|
+
):
|
|
491
|
+
"""List all subjects - navigation feed."""
|
|
492
|
+
base_url = get_base_url(request)
|
|
493
|
+
lib = get_library()
|
|
494
|
+
|
|
495
|
+
from .db.models import Subject
|
|
496
|
+
offset = (page - 1) * limit
|
|
497
|
+
|
|
498
|
+
subjects = lib.session.query(Subject).order_by(Subject.name).offset(offset).limit(limit).all()
|
|
499
|
+
total = lib.session.query(Subject).count()
|
|
500
|
+
|
|
501
|
+
entries = ""
|
|
502
|
+
for subject in subjects:
|
|
503
|
+
book_count = len(subject.books)
|
|
504
|
+
entries += f"""
|
|
505
|
+
<entry>
|
|
506
|
+
<id>urn:ebk:subject:{subject.id}</id>
|
|
507
|
+
<title>{escape_xml(subject.name)}</title>
|
|
508
|
+
<content type="text">{book_count} books</content>
|
|
509
|
+
<link rel="subsection" href="{base_url}/opds/subject/{subject.id}" type="{OPDS_ACQUISITION_MIME}"/>
|
|
510
|
+
<updated>{format_datetime()}</updated>
|
|
511
|
+
</entry>"""
|
|
512
|
+
|
|
513
|
+
# Pagination links
|
|
514
|
+
links = ""
|
|
515
|
+
if page > 1:
|
|
516
|
+
links += f'<link rel="previous" href="{base_url}/opds/subjects?page={page-1}&limit={limit}" type="{OPDS_MIME}"/>'
|
|
517
|
+
if offset + len(subjects) < total:
|
|
518
|
+
links += f'<link rel="next" href="{base_url}/opds/subjects?page={page+1}&limit={limit}" type="{OPDS_MIME}"/>'
|
|
519
|
+
|
|
520
|
+
feed = build_feed(
|
|
521
|
+
id="urn:ebk:subjects",
|
|
522
|
+
title="Subjects",
|
|
523
|
+
entries=entries,
|
|
524
|
+
base_url=base_url,
|
|
525
|
+
links=links,
|
|
526
|
+
total_results=total,
|
|
527
|
+
start_index=offset + 1,
|
|
528
|
+
items_per_page=limit,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
return Response(content=feed, media_type=OPDS_MIME)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@router.get("/subject/{subject_id}", response_class=Response)
|
|
535
|
+
async def opds_subject_books(
|
|
536
|
+
request: Request,
|
|
537
|
+
subject_id: int,
|
|
538
|
+
page: int = Query(1, ge=1),
|
|
539
|
+
limit: int = Query(50, ge=1, le=100),
|
|
540
|
+
):
|
|
541
|
+
"""Books in a specific subject."""
|
|
542
|
+
base_url = get_base_url(request)
|
|
543
|
+
lib = get_library()
|
|
544
|
+
|
|
545
|
+
from .db.models import Subject
|
|
546
|
+
subject = lib.session.query(Subject).filter(Subject.id == subject_id).first()
|
|
547
|
+
|
|
548
|
+
if not subject:
|
|
549
|
+
raise HTTPException(status_code=404, detail="Subject not found")
|
|
550
|
+
|
|
551
|
+
offset = (page - 1) * limit
|
|
552
|
+
books = subject.books[offset:offset + limit]
|
|
553
|
+
total = len(subject.books)
|
|
554
|
+
|
|
555
|
+
entries = "".join(build_entry(book, base_url) for book in books)
|
|
556
|
+
|
|
557
|
+
# Pagination links
|
|
558
|
+
links = ""
|
|
559
|
+
if page > 1:
|
|
560
|
+
links += f'<link rel="previous" href="{base_url}/opds/subject/{subject_id}?page={page-1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
561
|
+
if offset + len(books) < total:
|
|
562
|
+
links += f'<link rel="next" href="{base_url}/opds/subject/{subject_id}?page={page+1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
563
|
+
|
|
564
|
+
feed = build_feed(
|
|
565
|
+
id=f"urn:ebk:subject:{subject_id}",
|
|
566
|
+
title=f"Subject: {subject.name}",
|
|
567
|
+
entries=entries,
|
|
568
|
+
base_url=base_url,
|
|
569
|
+
links=links,
|
|
570
|
+
total_results=total,
|
|
571
|
+
start_index=offset + 1,
|
|
572
|
+
items_per_page=limit,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@router.get("/languages", response_class=Response)
|
|
579
|
+
async def opds_languages(request: Request):
|
|
580
|
+
"""List all languages - navigation feed."""
|
|
581
|
+
base_url = get_base_url(request)
|
|
582
|
+
lib = get_library()
|
|
583
|
+
|
|
584
|
+
from .db.models import Book
|
|
585
|
+
from sqlalchemy import func
|
|
586
|
+
|
|
587
|
+
# Get languages with book counts
|
|
588
|
+
languages = (lib.session.query(Book.language, func.count(Book.id))
|
|
589
|
+
.filter(Book.language.isnot(None))
|
|
590
|
+
.group_by(Book.language)
|
|
591
|
+
.order_by(func.count(Book.id).desc())
|
|
592
|
+
.all())
|
|
593
|
+
|
|
594
|
+
entries = ""
|
|
595
|
+
for lang, count in languages:
|
|
596
|
+
entries += f"""
|
|
597
|
+
<entry>
|
|
598
|
+
<id>urn:ebk:language:{lang}</id>
|
|
599
|
+
<title>{escape_xml(lang)}</title>
|
|
600
|
+
<content type="text">{count} books</content>
|
|
601
|
+
<link rel="subsection" href="{base_url}/opds/language/{quote(lang)}" type="{OPDS_ACQUISITION_MIME}"/>
|
|
602
|
+
<updated>{format_datetime()}</updated>
|
|
603
|
+
</entry>"""
|
|
604
|
+
|
|
605
|
+
feed = build_feed(
|
|
606
|
+
id="urn:ebk:languages",
|
|
607
|
+
title="Languages",
|
|
608
|
+
entries=entries,
|
|
609
|
+
base_url=base_url,
|
|
610
|
+
total_results=len(languages),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
return Response(content=feed, media_type=OPDS_MIME)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@router.get("/language/{lang}", response_class=Response)
|
|
617
|
+
async def opds_language_books(
|
|
618
|
+
request: Request,
|
|
619
|
+
lang: str,
|
|
620
|
+
page: int = Query(1, ge=1),
|
|
621
|
+
limit: int = Query(50, ge=1, le=100),
|
|
622
|
+
):
|
|
623
|
+
"""Books in a specific language."""
|
|
624
|
+
base_url = get_base_url(request)
|
|
625
|
+
lib = get_library()
|
|
626
|
+
|
|
627
|
+
offset = (page - 1) * limit
|
|
628
|
+
total = lib.query().filter_by_language(lang).count()
|
|
629
|
+
books = lib.query().filter_by_language(lang).order_by('title').limit(limit).offset(offset).all()
|
|
630
|
+
|
|
631
|
+
entries = "".join(build_entry(book, base_url) for book in books)
|
|
632
|
+
|
|
633
|
+
# Pagination links
|
|
634
|
+
links = ""
|
|
635
|
+
if page > 1:
|
|
636
|
+
links += f'<link rel="previous" href="{base_url}/opds/language/{quote(lang)}?page={page-1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
637
|
+
if offset + len(books) < total:
|
|
638
|
+
links += f'<link rel="next" href="{base_url}/opds/language/{quote(lang)}?page={page+1}&limit={limit}" type="{OPDS_ACQUISITION_MIME}"/>'
|
|
639
|
+
|
|
640
|
+
feed = build_feed(
|
|
641
|
+
id=f"urn:ebk:language:{lang}",
|
|
642
|
+
title=f"Language: {lang}",
|
|
643
|
+
entries=entries,
|
|
644
|
+
base_url=base_url,
|
|
645
|
+
links=links,
|
|
646
|
+
total_results=total,
|
|
647
|
+
start_index=offset + 1,
|
|
648
|
+
items_per_page=limit,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@router.get("/book/{book_id}", response_class=Response)
|
|
655
|
+
async def opds_book_detail(request: Request, book_id: int):
|
|
656
|
+
"""Single book detail - acquisition feed."""
|
|
657
|
+
base_url = get_base_url(request)
|
|
658
|
+
lib = get_library()
|
|
659
|
+
|
|
660
|
+
book = lib.get_book(book_id)
|
|
661
|
+
if not book:
|
|
662
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
663
|
+
|
|
664
|
+
entry = build_entry(book, base_url)
|
|
665
|
+
|
|
666
|
+
feed = build_feed(
|
|
667
|
+
id=f"urn:ebk:book:{book_id}",
|
|
668
|
+
title=book.title or "Untitled",
|
|
669
|
+
entries=entry,
|
|
670
|
+
base_url=base_url,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
return Response(content=feed, media_type=OPDS_ACQUISITION_MIME)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@router.get("/download/{book_id}/{format}")
|
|
677
|
+
async def opds_download(book_id: int, format: str):
|
|
678
|
+
"""Download a book file."""
|
|
679
|
+
lib = get_library()
|
|
680
|
+
|
|
681
|
+
book = lib.get_book(book_id)
|
|
682
|
+
if not book:
|
|
683
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
684
|
+
|
|
685
|
+
# Find file with requested format
|
|
686
|
+
file = None
|
|
687
|
+
for f in book.files:
|
|
688
|
+
if f.format.lower() == format.lower():
|
|
689
|
+
file = f
|
|
690
|
+
break
|
|
691
|
+
|
|
692
|
+
if not file:
|
|
693
|
+
raise HTTPException(status_code=404, detail=f"Format {format} not found for this book")
|
|
694
|
+
|
|
695
|
+
# Resolve relative path against library directory
|
|
696
|
+
file_path = lib.library_path / file.path
|
|
697
|
+
if not file_path.exists():
|
|
698
|
+
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
699
|
+
|
|
700
|
+
# Generate filename
|
|
701
|
+
safe_title = "".join(c for c in (book.title or "book") if c.isalnum() or c in " -_")[:50]
|
|
702
|
+
filename = f"{safe_title}.{format}"
|
|
703
|
+
|
|
704
|
+
return FileResponse(
|
|
705
|
+
path=file_path,
|
|
706
|
+
filename=filename,
|
|
707
|
+
media_type=get_mime_type(format),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@router.get("/cover/{book_id}")
|
|
712
|
+
async def opds_cover(book_id: int):
|
|
713
|
+
"""Get book cover image."""
|
|
714
|
+
lib = get_library()
|
|
715
|
+
|
|
716
|
+
book = lib.get_book(book_id)
|
|
717
|
+
if not book or not book.covers:
|
|
718
|
+
raise HTTPException(status_code=404, detail="Cover not found")
|
|
719
|
+
|
|
720
|
+
cover = book.covers[0]
|
|
721
|
+
if not cover.path:
|
|
722
|
+
raise HTTPException(status_code=404, detail="Cover path not set")
|
|
723
|
+
|
|
724
|
+
# Resolve relative path against library directory
|
|
725
|
+
cover_path = lib.library_path / cover.path
|
|
726
|
+
|
|
727
|
+
if not cover_path.exists():
|
|
728
|
+
raise HTTPException(status_code=404, detail="Cover file not found")
|
|
729
|
+
|
|
730
|
+
# Determine media type from extension
|
|
731
|
+
suffix = cover_path.suffix.lower()
|
|
732
|
+
media_type = {
|
|
733
|
+
'.jpg': 'image/jpeg',
|
|
734
|
+
'.jpeg': 'image/jpeg',
|
|
735
|
+
'.png': 'image/png',
|
|
736
|
+
'.gif': 'image/gif',
|
|
737
|
+
'.webp': 'image/webp',
|
|
738
|
+
}.get(suffix, 'image/jpeg')
|
|
739
|
+
|
|
740
|
+
return FileResponse(path=cover_path, media_type=media_type)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@router.get("/cover/{book_id}/thumbnail")
|
|
744
|
+
async def opds_cover_thumbnail(book_id: int):
|
|
745
|
+
"""Get book cover thumbnail (falls back to full cover)."""
|
|
746
|
+
# Just return the full cover for now
|
|
747
|
+
# TODO: Generate actual thumbnails if needed
|
|
748
|
+
return await opds_cover(book_id)
|