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.
Files changed (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. 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("<", "&lt;")
61
+ .replace(">", "&gt;")
62
+ .replace('"', "&quot;")
63
+ .replace("'", "&apos;"))
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}}&amp;page={{startPage?}}&amp;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}&amp;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}&amp;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)}&amp;page={page-1}&amp;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)}&amp;page={page+1}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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}&amp;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)