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
@@ -0,0 +1,577 @@
1
+ """
2
+ Export service for library data.
3
+
4
+ Provides a unified interface for exporting library data in various formats:
5
+ - JSON: Machine-readable data export
6
+ - CSV: Spreadsheet-compatible format
7
+ - HTML: Standalone web interface
8
+ - OPDS: E-reader compatible catalog
9
+ """
10
+
11
+ import json
12
+ import csv
13
+ import io
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import List, Dict, Any, Optional
17
+ from datetime import datetime
18
+ import logging
19
+
20
+ from sqlalchemy.orm import Session
21
+
22
+ from ..db.models import Book
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ExportService:
28
+ """Service for exporting library data in various formats."""
29
+
30
+ def __init__(self, session: Session, library_path: Optional[Path] = None):
31
+ """
32
+ Initialize the export service.
33
+
34
+ Args:
35
+ session: SQLAlchemy database session
36
+ library_path: Path to the library root (needed for file copying)
37
+ """
38
+ self.session = session
39
+ self.library_path = Path(library_path) if library_path else None
40
+
41
+ def export_json(
42
+ self,
43
+ books: List[Book],
44
+ include_annotations: bool = True,
45
+ include_personal: bool = True,
46
+ pretty: bool = True,
47
+ ) -> str:
48
+ """
49
+ Export books to JSON format.
50
+
51
+ Args:
52
+ books: List of Book objects to export
53
+ include_annotations: Include notes and annotations
54
+ include_personal: Include ratings, favorites, reading status
55
+ pretty: Pretty-print the JSON output
56
+
57
+ Returns:
58
+ JSON string representation of the books
59
+ """
60
+ export_data = {
61
+ "exported_at": datetime.now().isoformat(),
62
+ "total_books": len(books),
63
+ "books": []
64
+ }
65
+
66
+ for book in books:
67
+ book_data = self._book_to_dict(book, include_annotations, include_personal)
68
+ export_data["books"].append(book_data)
69
+
70
+ if pretty:
71
+ return json.dumps(export_data, indent=2, ensure_ascii=False)
72
+ return json.dumps(export_data, ensure_ascii=False)
73
+
74
+ def export_csv(
75
+ self,
76
+ books: List[Book],
77
+ fields: Optional[List[str]] = None,
78
+ ) -> str:
79
+ """
80
+ Export books to CSV format.
81
+
82
+ Args:
83
+ books: List of Book objects to export
84
+ fields: List of field names to include (None = default fields)
85
+
86
+ Returns:
87
+ CSV string representation of the books
88
+ """
89
+ if fields is None:
90
+ fields = [
91
+ "id", "title", "authors", "language", "publisher",
92
+ "publication_date", "subjects", "formats", "rating",
93
+ "favorite", "reading_status"
94
+ ]
95
+
96
+ output = io.StringIO()
97
+ writer = csv.DictWriter(output, fieldnames=fields, extrasaction='ignore')
98
+ writer.writeheader()
99
+
100
+ for book in books:
101
+ row = self._book_to_csv_row(book)
102
+ writer.writerow(row)
103
+
104
+ return output.getvalue()
105
+
106
+ def export_html(
107
+ self,
108
+ books: List[Book],
109
+ output_path: Path,
110
+ include_stats: bool = True,
111
+ base_url: str = "",
112
+ views: Optional[List[Dict[str, Any]]] = None,
113
+ copy_files: bool = False,
114
+ ) -> Dict[str, Any]:
115
+ """
116
+ Export books to a standalone HTML file.
117
+
118
+ Args:
119
+ books: List of Book objects to export
120
+ output_path: Path to output HTML file
121
+ include_stats: Include library statistics
122
+ base_url: Base URL for file links
123
+ views: List of view definitions for sidebar
124
+ copy_files: Copy ebook/cover files to output directory
125
+
126
+ Returns:
127
+ Dictionary with export statistics
128
+ """
129
+ from ..exports.html_library import export_to_html
130
+
131
+ output_path = Path(output_path)
132
+ stats = {
133
+ "books": len(books),
134
+ "files_copied": 0,
135
+ "covers_copied": 0,
136
+ }
137
+
138
+ # Copy files if requested
139
+ if copy_files and self.library_path:
140
+ stats.update(self._copy_files(books, output_path.parent, base_url))
141
+
142
+ # Export HTML
143
+ export_to_html(
144
+ books=books,
145
+ output_path=output_path,
146
+ include_stats=include_stats,
147
+ base_url=base_url,
148
+ views=views,
149
+ )
150
+
151
+ return stats
152
+
153
+ def export_opds(
154
+ self,
155
+ books: List[Book],
156
+ output_path: Path,
157
+ title: str = "ebk Library",
158
+ subtitle: str = "",
159
+ base_url: str = "",
160
+ copy_files: bool = False,
161
+ copy_covers: bool = False,
162
+ ) -> Dict[str, Any]:
163
+ """
164
+ Export books to an OPDS catalog file.
165
+
166
+ Args:
167
+ books: List of Book objects to export
168
+ output_path: Path to output XML file
169
+ title: Catalog title
170
+ subtitle: Catalog subtitle
171
+ base_url: Base URL for file/cover links
172
+ copy_files: Copy ebook files to output directory
173
+ copy_covers: Copy cover images to output directory
174
+
175
+ Returns:
176
+ Dictionary with export statistics
177
+ """
178
+ from ..exports.opds_export import export_to_opds
179
+
180
+ if not self.library_path:
181
+ raise ValueError("library_path required for OPDS export")
182
+
183
+ return export_to_opds(
184
+ books=books,
185
+ output_path=output_path,
186
+ library_path=self.library_path,
187
+ title=title,
188
+ subtitle=subtitle,
189
+ base_url=base_url,
190
+ copy_files=copy_files,
191
+ copy_covers=copy_covers,
192
+ )
193
+
194
+ def get_views_data(
195
+ self,
196
+ books: List[Book],
197
+ include_builtin: bool = True,
198
+ ) -> List[Dict[str, Any]]:
199
+ """
200
+ Get views data for export, with book IDs resolved.
201
+
202
+ Args:
203
+ books: List of books being exported
204
+ include_builtin: Include builtin views
205
+
206
+ Returns:
207
+ List of view definitions with resolved book_ids
208
+ """
209
+ from ..views import ViewService
210
+
211
+ views_svc = ViewService(self.session)
212
+ all_views = views_svc.list(include_builtin=include_builtin)
213
+
214
+ book_ids_set = {b.id for b in books}
215
+ views_data = []
216
+
217
+ for v in all_views:
218
+ try:
219
+ view_books = views_svc.evaluate(v['name'])
220
+ view_book_ids = [tb.book.id for tb in view_books if tb.book.id in book_ids_set]
221
+ if view_book_ids:
222
+ views_data.append({
223
+ 'name': v['name'],
224
+ 'description': v.get('description', ''),
225
+ 'book_ids': view_book_ids,
226
+ 'builtin': v.get('builtin', False)
227
+ })
228
+ except Exception:
229
+ pass # Skip views that fail to evaluate
230
+
231
+ return views_data
232
+
233
+ def _book_to_dict(
234
+ self,
235
+ book: Book,
236
+ include_annotations: bool = True,
237
+ include_personal: bool = True,
238
+ ) -> Dict[str, Any]:
239
+ """Convert a Book object to a dictionary for export."""
240
+ data = {
241
+ "id": book.id,
242
+ "unique_id": book.unique_id,
243
+ "title": book.title,
244
+ "subtitle": book.subtitle,
245
+ "authors": [a.name for a in book.authors],
246
+ "language": book.language,
247
+ "publisher": book.publisher,
248
+ "publication_date": book.publication_date,
249
+ "description": book.description,
250
+ "subjects": [s.name for s in book.subjects],
251
+ "series": book.series,
252
+ "series_index": book.series_index,
253
+ "identifiers": {i.scheme: i.value for i in book.identifiers},
254
+ "files": [
255
+ {
256
+ "format": f.format,
257
+ "path": f.path,
258
+ "size_bytes": f.size_bytes,
259
+ "file_hash": f.file_hash,
260
+ }
261
+ for f in book.files
262
+ ],
263
+ "covers": [
264
+ {
265
+ "path": c.path,
266
+ "width": c.width,
267
+ "height": c.height,
268
+ "is_primary": c.is_primary,
269
+ }
270
+ for c in book.covers
271
+ ],
272
+ "tags": [t.full_path for t in book.tags] if book.tags else [],
273
+ "created_at": book.created_at.isoformat() if book.created_at else None,
274
+ "updated_at": book.updated_at.isoformat() if book.updated_at else None,
275
+ }
276
+
277
+ if include_personal and book.personal:
278
+ pm = book.personal
279
+ data["personal"] = {
280
+ "rating": pm.rating,
281
+ "is_favorite": pm.favorite,
282
+ "reading_status": pm.reading_status,
283
+ "reading_progress": pm.reading_progress,
284
+ "date_started": pm.date_started.isoformat() if pm.date_started else None,
285
+ "date_finished": pm.date_finished.isoformat() if pm.date_finished else None,
286
+ "personal_tags": pm.personal_tags or [],
287
+ "queue_position": pm.queue_position,
288
+ }
289
+
290
+ if include_annotations:
291
+ data["annotations"] = [
292
+ {
293
+ "id": a.id,
294
+ "type": a.annotation_type,
295
+ "content": a.content,
296
+ "page": a.page_number,
297
+ "created_at": a.created_at.isoformat() if a.created_at else None,
298
+ }
299
+ for a in book.annotations
300
+ ] if hasattr(book, 'annotations') and book.annotations else []
301
+
302
+ return data
303
+
304
+ def _book_to_csv_row(self, book: Book) -> Dict[str, Any]:
305
+ """Convert a Book object to a CSV row dictionary."""
306
+ pm = book.personal
307
+ return {
308
+ "id": book.id,
309
+ "unique_id": book.unique_id,
310
+ "title": book.title,
311
+ "subtitle": book.subtitle or "",
312
+ "authors": "; ".join(a.name for a in book.authors),
313
+ "language": book.language or "",
314
+ "publisher": book.publisher or "",
315
+ "publication_date": book.publication_date or "",
316
+ "description": (book.description or "")[:200], # Truncate for CSV
317
+ "subjects": "; ".join(s.name for s in book.subjects),
318
+ "series": book.series or "",
319
+ "series_index": book.series_index or "",
320
+ "formats": ", ".join(f.format for f in book.files),
321
+ "tags": "; ".join(t.full_path for t in book.tags) if book.tags else "",
322
+ "rating": pm.rating if pm else "",
323
+ "favorite": "yes" if pm and pm.favorite else "no",
324
+ "reading_status": pm.reading_status if pm else "",
325
+ }
326
+
327
+ def export_goodreads_csv(self, books: List[Book]) -> str:
328
+ """
329
+ Export books to Goodreads-compatible CSV format.
330
+
331
+ The exported CSV can be imported into Goodreads via their import feature.
332
+ See: https://www.goodreads.com/review/import
333
+
334
+ Args:
335
+ books: List of Book objects to export
336
+
337
+ Returns:
338
+ CSV string in Goodreads format
339
+ """
340
+ # Goodreads CSV columns (required and optional)
341
+ fields = [
342
+ "Title", "Author", "Additional Authors", "ISBN", "ISBN13",
343
+ "My Rating", "Average Rating", "Publisher", "Binding", "Number of Pages",
344
+ "Year Published", "Original Publication Year", "Date Read", "Date Added",
345
+ "Bookshelves", "Bookshelves with positions", "Exclusive Shelf",
346
+ "My Review", "Spoiler", "Private Notes", "Read Count", "Owned Copies"
347
+ ]
348
+
349
+ output = io.StringIO()
350
+ writer = csv.DictWriter(output, fieldnames=fields, extrasaction='ignore')
351
+ writer.writeheader()
352
+
353
+ for book in books:
354
+ pm = book.personal
355
+
356
+ # Get authors
357
+ authors = list(book.authors)
358
+ primary_author = authors[0].name if authors else ""
359
+ additional_authors = ", ".join(a.name for a in authors[1:]) if len(authors) > 1 else ""
360
+
361
+ # Get identifiers
362
+ identifiers = {i.scheme.lower(): i.value for i in book.identifiers}
363
+ isbn = identifiers.get('isbn', '')
364
+ isbn13 = identifiers.get('isbn13', '')
365
+
366
+ # Map reading status to Goodreads exclusive shelf
367
+ status_map = {
368
+ 'read': 'read',
369
+ 'reading': 'currently-reading',
370
+ 'to_read': 'to-read',
371
+ 'unread': 'to-read',
372
+ 'abandoned': 'read', # No abandoned shelf in Goodreads
373
+ 'reference': 'read',
374
+ }
375
+ status = pm.reading_status if pm else 'unread'
376
+ exclusive_shelf = status_map.get(status, 'to-read')
377
+
378
+ # Convert rating (ebk uses 0-5, Goodreads uses 1-5)
379
+ rating = ""
380
+ if pm and pm.rating:
381
+ # Round to integer, minimum 1
382
+ rating = str(max(1, round(pm.rating)))
383
+
384
+ # Get bookshelves (tags)
385
+ bookshelves = []
386
+ if book.tags:
387
+ bookshelves.extend(t.name for t in book.tags)
388
+ if pm and pm.personal_tags:
389
+ bookshelves.extend(pm.personal_tags)
390
+
391
+ # Format dates
392
+ date_read = ""
393
+ date_added = ""
394
+ if pm:
395
+ if pm.date_finished:
396
+ date_read = pm.date_finished.strftime("%Y/%m/%d")
397
+ if book.created_at:
398
+ date_added = book.created_at.strftime("%Y/%m/%d")
399
+
400
+ # Parse publication year
401
+ pub_year = ""
402
+ if book.publication_date:
403
+ # Try to extract year from various formats
404
+ pub_date = book.publication_date
405
+ if len(pub_date) >= 4 and pub_date[:4].isdigit():
406
+ pub_year = pub_date[:4]
407
+
408
+ row = {
409
+ "Title": book.title or "",
410
+ "Author": primary_author,
411
+ "Additional Authors": additional_authors,
412
+ "ISBN": isbn,
413
+ "ISBN13": isbn13,
414
+ "My Rating": rating,
415
+ "Average Rating": "", # We don't have this
416
+ "Publisher": book.publisher or "",
417
+ "Binding": "", # Could map from file format
418
+ "Number of Pages": str(book.page_count) if book.page_count else "",
419
+ "Year Published": pub_year,
420
+ "Original Publication Year": pub_year,
421
+ "Date Read": date_read,
422
+ "Date Added": date_added,
423
+ "Bookshelves": ", ".join(bookshelves),
424
+ "Bookshelves with positions": "",
425
+ "Exclusive Shelf": exclusive_shelf,
426
+ "My Review": "", # Could add from annotations
427
+ "Spoiler": "",
428
+ "Private Notes": "",
429
+ "Read Count": "1" if status == 'read' else "0",
430
+ "Owned Copies": "1",
431
+ }
432
+ writer.writerow(row)
433
+
434
+ return output.getvalue()
435
+
436
+ def export_calibre_csv(self, books: List[Book]) -> str:
437
+ """
438
+ Export books to Calibre-compatible CSV format.
439
+
440
+ The exported CSV can be imported into Calibre using the "Add books" >
441
+ "Add from ISBN" or via calibredb command-line tool.
442
+
443
+ Args:
444
+ books: List of Book objects to export
445
+
446
+ Returns:
447
+ CSV string in Calibre format
448
+ """
449
+ # Calibre CSV columns
450
+ fields = [
451
+ "title", "authors", "author_sort", "publisher", "pubdate",
452
+ "languages", "rating", "tags", "series", "series_index",
453
+ "identifiers", "comments", "isbn"
454
+ ]
455
+
456
+ output = io.StringIO()
457
+ writer = csv.DictWriter(output, fieldnames=fields, extrasaction='ignore')
458
+ writer.writeheader()
459
+
460
+ for book in books:
461
+ pm = book.personal
462
+
463
+ # Get authors
464
+ authors = " & ".join(a.name for a in book.authors)
465
+ # Author sort: "Last, First & Last, First"
466
+ author_sort_parts = []
467
+ for a in book.authors:
468
+ parts = a.name.split()
469
+ if len(parts) >= 2:
470
+ author_sort_parts.append(f"{parts[-1]}, {' '.join(parts[:-1])}")
471
+ else:
472
+ author_sort_parts.append(a.name)
473
+ author_sort = " & ".join(author_sort_parts)
474
+
475
+ # Get identifiers in Calibre format: isbn:123,asin:B00...
476
+ identifiers = {i.scheme.lower(): i.value for i in book.identifiers}
477
+ id_str = ",".join(f"{k}:{v}" for k, v in identifiers.items())
478
+ isbn = identifiers.get('isbn', identifiers.get('isbn13', ''))
479
+
480
+ # Collect tags
481
+ tags_list = []
482
+ if book.subjects:
483
+ tags_list.extend(s.name for s in book.subjects)
484
+ if book.tags:
485
+ tags_list.extend(t.full_path for t in book.tags)
486
+ if pm and pm.personal_tags:
487
+ tags_list.extend(pm.personal_tags)
488
+ # Add reading status as tag
489
+ if pm and pm.reading_status:
490
+ tags_list.append(f"status:{pm.reading_status}")
491
+ if pm and pm.favorite:
492
+ tags_list.append("favorite")
493
+
494
+ # Convert rating (ebk uses 0-5, Calibre uses 0-10)
495
+ rating = ""
496
+ if pm and pm.rating:
497
+ rating = str(int(pm.rating * 2))
498
+
499
+ # Language codes
500
+ language = book.language or ""
501
+
502
+ # Series info
503
+ series = book.series or ""
504
+ series_index = str(book.series_index) if book.series_index else ""
505
+
506
+ # Description/comments
507
+ comments = book.description or ""
508
+
509
+ row = {
510
+ "title": book.title or "",
511
+ "authors": authors,
512
+ "author_sort": author_sort,
513
+ "publisher": book.publisher or "",
514
+ "pubdate": book.publication_date or "",
515
+ "languages": language,
516
+ "rating": rating,
517
+ "tags": ", ".join(tags_list),
518
+ "series": series,
519
+ "series_index": series_index,
520
+ "identifiers": id_str,
521
+ "comments": comments,
522
+ "isbn": isbn,
523
+ }
524
+ writer.writerow(row)
525
+
526
+ return output.getvalue()
527
+
528
+ def _copy_files(
529
+ self,
530
+ books: List[Book],
531
+ output_dir: Path,
532
+ base_url: str,
533
+ ) -> Dict[str, int]:
534
+ """
535
+ Copy ebook and cover files to output directory.
536
+
537
+ Returns statistics about copied files.
538
+ """
539
+ if not self.library_path:
540
+ return {"files_copied": 0, "covers_copied": 0}
541
+
542
+ # Determine copy destination
543
+ copy_dest = output_dir / base_url.lstrip('/') if base_url else output_dir
544
+ copy_dest.mkdir(parents=True, exist_ok=True)
545
+
546
+ files_copied = 0
547
+ covers_copied = 0
548
+ total_size = 0
549
+
550
+ for book in books:
551
+ # Copy ebook files
552
+ for file in book.files:
553
+ src = self.library_path / file.path
554
+ dest = copy_dest / file.path
555
+
556
+ if src.exists():
557
+ dest.parent.mkdir(parents=True, exist_ok=True)
558
+ shutil.copy2(src, dest)
559
+ files_copied += 1
560
+ total_size += file.size_bytes or 0
561
+
562
+ # Copy cover images
563
+ for cover in book.covers:
564
+ src = self.library_path / cover.path
565
+ dest = copy_dest / cover.path
566
+
567
+ if src.exists():
568
+ dest.parent.mkdir(parents=True, exist_ok=True)
569
+ shutil.copy2(src, dest)
570
+ covers_copied += 1
571
+ total_size += src.stat().st_size
572
+
573
+ return {
574
+ "files_copied": files_copied,
575
+ "covers_copied": covers_copied,
576
+ "total_size_bytes": total_size,
577
+ }