comiccatcher 0.1.0a2__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 (114) hide show
  1. comiccatcher/__init__.py +38 -0
  2. comiccatcher/_build_info.py +2 -0
  3. comiccatcher/api/__init__.py +0 -0
  4. comiccatcher/api/client.py +73 -0
  5. comiccatcher/api/download_manager.py +295 -0
  6. comiccatcher/api/feed_reconciler.py +596 -0
  7. comiccatcher/api/image_manager.py +178 -0
  8. comiccatcher/api/library_scanner.py +120 -0
  9. comiccatcher/api/local_db.py +323 -0
  10. comiccatcher/api/opds_v2.py +133 -0
  11. comiccatcher/api/progression.py +94 -0
  12. comiccatcher/config.py +313 -0
  13. comiccatcher/logger.py +44 -0
  14. comiccatcher/main.py +198 -0
  15. comiccatcher/models/__init__.py +0 -0
  16. comiccatcher/models/feed.py +20 -0
  17. comiccatcher/models/feed_page.py +134 -0
  18. comiccatcher/models/opds.py +186 -0
  19. comiccatcher/models/opds_auth.py +19 -0
  20. comiccatcher/resources/app.png +0 -0
  21. comiccatcher/resources/app_128.png +0 -0
  22. comiccatcher/resources/app_256.png +0 -0
  23. comiccatcher/resources/app_32.png +0 -0
  24. comiccatcher/resources/app_64.png +0 -0
  25. comiccatcher/resources/icons/action_delete.svg +6 -0
  26. comiccatcher/resources/icons/action_read.svg +4 -0
  27. comiccatcher/resources/icons/action_unread.svg +3 -0
  28. comiccatcher/resources/icons/back.svg +1 -0
  29. comiccatcher/resources/icons/book.svg +1 -0
  30. comiccatcher/resources/icons/chevron_down.svg +3 -0
  31. comiccatcher/resources/icons/chevron_left.svg +3 -0
  32. comiccatcher/resources/icons/chevron_right.svg +3 -0
  33. comiccatcher/resources/icons/chevrons_left.svg +4 -0
  34. comiccatcher/resources/icons/chevrons_right.svg +4 -0
  35. comiccatcher/resources/icons/close.svg +1 -0
  36. comiccatcher/resources/icons/download.svg +1 -0
  37. comiccatcher/resources/icons/eye.svg +4 -0
  38. comiccatcher/resources/icons/feeds.svg +1 -0
  39. comiccatcher/resources/icons/filter.svg +3 -0
  40. comiccatcher/resources/icons/focus_series.svg +1 -0
  41. comiccatcher/resources/icons/focus_title.svg +1 -0
  42. comiccatcher/resources/icons/folder.svg +1 -0
  43. comiccatcher/resources/icons/fullscreen.svg +1 -0
  44. comiccatcher/resources/icons/globe.svg +1 -0
  45. comiccatcher/resources/icons/group_by.svg +11 -0
  46. comiccatcher/resources/icons/group_misc.svg +1 -0
  47. comiccatcher/resources/icons/home.svg +1 -0
  48. comiccatcher/resources/icons/label.svg +1 -0
  49. comiccatcher/resources/icons/library.svg +7 -0
  50. comiccatcher/resources/icons/minimize.svg +1 -0
  51. comiccatcher/resources/icons/paging.svg +4 -0
  52. comiccatcher/resources/icons/plus.svg +4 -0
  53. comiccatcher/resources/icons/refresh.svg +1 -0
  54. comiccatcher/resources/icons/scrolling.svg +5 -0
  55. comiccatcher/resources/icons/search.svg +1 -0
  56. comiccatcher/resources/icons/select.svg +4 -0
  57. comiccatcher/resources/icons/settings.svg +11 -0
  58. comiccatcher/resources/icons/sort_added.svg +1 -0
  59. comiccatcher/resources/icons/sort_alpha.svg +1 -0
  60. comiccatcher/resources/icons/sort_asc.svg +1 -0
  61. comiccatcher/resources/icons/sort_date.svg +1 -0
  62. comiccatcher/resources/icons/sort_desc.svg +1 -0
  63. comiccatcher/resources/icons/view_file.svg +1 -0
  64. comiccatcher/resources/icons/view_grid.svg +1 -0
  65. comiccatcher/resources/icons/view_group.svg +11 -0
  66. comiccatcher/ui/__init__.py +0 -0
  67. comiccatcher/ui/app_layout.py +1451 -0
  68. comiccatcher/ui/base_reader.py +1749 -0
  69. comiccatcher/ui/components/__init__.py +0 -0
  70. comiccatcher/ui/components/auth_dialog.py +121 -0
  71. comiccatcher/ui/components/base_card_delegate.py +372 -0
  72. comiccatcher/ui/components/base_ribbon.py +111 -0
  73. comiccatcher/ui/components/collapsible_section.py +137 -0
  74. comiccatcher/ui/components/feed_browser_model.py +279 -0
  75. comiccatcher/ui/components/feed_card_delegate.py +124 -0
  76. comiccatcher/ui/components/library_card_delegate.py +67 -0
  77. comiccatcher/ui/components/loading_spinner.py +62 -0
  78. comiccatcher/ui/components/mini_detail_popover.py +516 -0
  79. comiccatcher/ui/components/paging_control.py +129 -0
  80. comiccatcher/ui/components/popover_mixin.py +87 -0
  81. comiccatcher/ui/components/section_header.py +89 -0
  82. comiccatcher/ui/debug_overlay.py +157 -0
  83. comiccatcher/ui/flow_layout.py +97 -0
  84. comiccatcher/ui/image_data.py +43 -0
  85. comiccatcher/ui/image_utils.py +75 -0
  86. comiccatcher/ui/local_archive.py +68 -0
  87. comiccatcher/ui/local_comicbox.py +273 -0
  88. comiccatcher/ui/reader_logic.py +140 -0
  89. comiccatcher/ui/theme_manager.py +1164 -0
  90. comiccatcher/ui/utils.py +75 -0
  91. comiccatcher/ui/view_helpers.py +126 -0
  92. comiccatcher/ui/views/__init__.py +0 -0
  93. comiccatcher/ui/views/base_browser.py +303 -0
  94. comiccatcher/ui/views/base_detail.py +397 -0
  95. comiccatcher/ui/views/base_feed_subview.py +192 -0
  96. comiccatcher/ui/views/downloads.py +230 -0
  97. comiccatcher/ui/views/feed_browser.py +1003 -0
  98. comiccatcher/ui/views/feed_detail.py +782 -0
  99. comiccatcher/ui/views/feed_list.py +182 -0
  100. comiccatcher/ui/views/feed_management.py +579 -0
  101. comiccatcher/ui/views/feed_reader.py +207 -0
  102. comiccatcher/ui/views/local_detail.py +294 -0
  103. comiccatcher/ui/views/local_library.py +1621 -0
  104. comiccatcher/ui/views/local_reader.py +204 -0
  105. comiccatcher/ui/views/paged_feed_view.py +290 -0
  106. comiccatcher/ui/views/scrolled_feed_view.py +903 -0
  107. comiccatcher/ui/views/search_root.py +200 -0
  108. comiccatcher/ui/views/settings.py +261 -0
  109. comiccatcher-0.1.0a2.dist-info/METADATA +484 -0
  110. comiccatcher-0.1.0a2.dist-info/RECORD +114 -0
  111. comiccatcher-0.1.0a2.dist-info/WHEEL +5 -0
  112. comiccatcher-0.1.0a2.dist-info/entry_points.txt +2 -0
  113. comiccatcher-0.1.0a2.dist-info/licenses/LICENSE +201 -0
  114. comiccatcher-0.1.0a2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,38 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ __version__ = "0.1.0a2"
5
+
6
+ def get_version_string() -> str:
7
+ """Returns the base version, optionally appended with git build info."""
8
+
9
+ # 1. Try to load pre-compiled build info (present in packaged releases)
10
+ try:
11
+ from comiccatcher._build_info import __commit__
12
+ if __commit__:
13
+ return f"{__version__} ({__commit__})"
14
+ except ImportError:
15
+ pass
16
+
17
+ # 2. Fallback to runtime dynamic git query (for local dev environments)
18
+ try:
19
+ # Resolve the root directory of the repository
20
+ repo_dir = Path(__file__).resolve().parent.parent.parent.parent
21
+ git_dir = repo_dir / ".git"
22
+
23
+ if git_dir.exists() and git_dir.is_dir():
24
+ result = subprocess.run(
25
+ ["git", "describe", "--tags", "--always", "--dirty"],
26
+ cwd=str(repo_dir),
27
+ capture_output=True,
28
+ text=True,
29
+ check=True,
30
+ timeout=1
31
+ )
32
+ git_info = result.stdout.strip()
33
+ if git_info:
34
+ return f"{__version__} ({git_info})"
35
+ except Exception:
36
+ pass
37
+
38
+ return __version__
@@ -0,0 +1,2 @@
1
+ # Auto-generated by build_package.sh
2
+ __commit__ = "v0.1.0a2"
File without changes
@@ -0,0 +1,73 @@
1
+ import httpx
2
+ import locale
3
+ import platform
4
+ from typing import Optional, Dict, Any
5
+ from comiccatcher import __version__
6
+ from comiccatcher.models.feed import FeedProfile
7
+ from comiccatcher.config import NETWORK_TIMEOUT
8
+
9
+ class APIClient:
10
+ def __init__(self, profile: FeedProfile):
11
+ self.profile = profile
12
+ self.client = httpx.AsyncClient(
13
+ base_url=self.profile.get_base_url(),
14
+ timeout=NETWORK_TIMEOUT,
15
+ follow_redirects=True
16
+ )
17
+
18
+ self._setup_headers()
19
+ self._setup_auth()
20
+
21
+ def _setup_headers(self):
22
+ """Sets up default headers, including system language and User-Agent."""
23
+ # 1. User-Agent
24
+ ua = f"comiccatcher/{__version__} ({platform.system()}; Desktop)"
25
+ self.client.headers.update({"User-Agent": ua})
26
+
27
+ # 2. Accept-Language
28
+ try:
29
+ # Try to get the system's preferred language/region (e.g. ('en_US', 'UTF-8'))
30
+ lang, encoding = locale.getlocale()
31
+ if lang:
32
+ # Standardize to RFC 2616 format: primary-subtag (e.g. en-US)
33
+ accept_lang = lang.replace('_', '-')
34
+ self.client.headers.update({"Accept-Language": f"{accept_lang}, *;q=0.5"})
35
+ except Exception:
36
+ # Fallback if locale detection fails
37
+ pass
38
+
39
+ def _setup_auth(self):
40
+ # Determine authentication method based on provided credentials
41
+ mode = self.profile.auth_type
42
+
43
+ if mode == "apikey" and self.profile.api_key:
44
+ self.client.headers.update({"X-API-Key": self.profile.api_key})
45
+ elif mode == "bearer" and self.profile.bearer_token:
46
+ self.client.headers.update({"Authorization": f"Bearer {self.profile.bearer_token}"})
47
+ elif mode == "basic" and self.profile.username and self.profile.password:
48
+ self.client.auth = httpx.BasicAuth(self.profile.username, self.profile.password)
49
+
50
+ # Legacy fallback if auth_type is not set (e.g. older config files)
51
+ if mode == "none":
52
+ if self.profile.bearer_token:
53
+ self.client.headers.update({"Authorization": f"Bearer {self.profile.bearer_token}"})
54
+ elif self.profile.username and self.profile.password:
55
+ self.client.auth = httpx.BasicAuth(self.profile.username, self.profile.password)
56
+
57
+ async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> httpx.Response:
58
+ return await self.client.get(endpoint, params=params, timeout=timeout)
59
+
60
+ async def post(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> httpx.Response:
61
+ return await self.client.post(endpoint, json=json)
62
+
63
+ async def put(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> httpx.Response:
64
+ return await self.client.put(endpoint, json=json)
65
+
66
+ async def close(self):
67
+ await self.client.aclose()
68
+
69
+ async def __aenter__(self):
70
+ return self
71
+
72
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
73
+ await self.close()
@@ -0,0 +1,295 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import Dict, Optional, Callable, List
7
+ from urllib.parse import unquote_plus, urlsplit
8
+ import httpx
9
+ from comiccatcher.api.client import APIClient
10
+ from comiccatcher.logger import get_logger
11
+
12
+ logger = get_logger("api.download_manager")
13
+
14
+ _FILENAME_MAX = 180
15
+
16
+
17
+ def _iterative_unquote_plus(s: str, max_rounds: int = 3) -> str:
18
+ """
19
+ Decode strings that may be encoded multiple times (e.g. %2523 -> %23 -> #).
20
+ Also treats '+' as space (like browsers do for form-style encodings).
21
+ """
22
+ out = s or ""
23
+ for _ in range(max_rounds):
24
+ new = unquote_plus(out)
25
+ if new == out:
26
+ break
27
+ out = new
28
+ return out
29
+
30
+
31
+ def _filename_from_content_disposition(cd: str) -> Optional[str]:
32
+ """
33
+ Parse Content-Disposition and return a decoded filename if present.
34
+
35
+ Supports:
36
+ - filename="x.cbz"
37
+ - filename*=UTF-8''x%20y.cbz
38
+ """
39
+ if not cd:
40
+ return None
41
+
42
+ # Prefer RFC 5987 filename*
43
+ m = re.search(r"filename\*\s*=\s*([^;]+)", cd, flags=re.IGNORECASE)
44
+ if m:
45
+ raw = m.group(1).strip().strip("\"'")
46
+ if "''" in raw:
47
+ _, _, rest = raw.partition("''")
48
+ return _iterative_unquote_plus(rest)
49
+ return _iterative_unquote_plus(raw)
50
+
51
+ m = re.search(r"filename\s*=\s*([^;]+)", cd, flags=re.IGNORECASE)
52
+ if m:
53
+ raw = m.group(1).strip().strip("\"'")
54
+ return _iterative_unquote_plus(raw)
55
+ return None
56
+
57
+
58
+ def _filename_from_url(url: str) -> Optional[str]:
59
+ try:
60
+ leaf = Path(urlsplit(url).path).name
61
+ if not leaf:
62
+ return None
63
+ return _iterative_unquote_plus(leaf)
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ def _sanitize_filename(name: str, mime_type: Optional[str] = None) -> str:
69
+ """
70
+ Keep names user-friendly while being safe across OSes.
71
+ Rely on the provided mime_type to determine the correct extension.
72
+ """
73
+ if not name:
74
+ name = "download"
75
+
76
+ name = Path(str(name)).name
77
+ # Remove existing extension to re-apply correctly based on MIME
78
+ stem = Path(name).stem
79
+ name = re.sub(r"\s+", " ", stem).strip()
80
+
81
+ allowed = set(" ._-#()[]")
82
+ cleaned = "".join(c for c in name if c.isalnum() or c in allowed).strip(" .")
83
+ if not cleaned:
84
+ cleaned = "download"
85
+
86
+ # Map MIME to extension
87
+ MIME_MAP = {
88
+ "application/vnd.comicbook+zip": ".cbz",
89
+ "application/x-cbz": ".cbz",
90
+ "application/zip": ".cbz",
91
+ "application/vnd.comicbook-rar": ".cbr",
92
+ "application/x-cbr": ".cbr",
93
+ "application/x-rar": ".cbr",
94
+ "application/x-rar-compressed": ".cbr",
95
+ "application/x-cb7": ".cb7",
96
+ "application/x-7z-compressed": ".cb7",
97
+ "application/x-cbt": ".cbt",
98
+ "application/x-tar": ".cbt",
99
+ "application/pdf": ".pdf",
100
+ "application/epub+zip": ".epub"
101
+ }
102
+
103
+ ext = ".cbz" # Default fallback
104
+ if mime_type:
105
+ ext = MIME_MAP.get(mime_type.lower().split(";")[0].strip(), ".cbz")
106
+
107
+ # Check if stem already has the correct extension (e.g. from Content-Disposition)
108
+ if cleaned.lower().endswith(ext):
109
+ final_name = cleaned
110
+ else:
111
+ final_name = f"{cleaned}{ext}"
112
+
113
+ if len(final_name) > _FILENAME_MAX:
114
+ final_name = final_name[: _FILENAME_MAX - len(ext)] + ext
115
+
116
+ return final_name
117
+
118
+
119
+ def _collision_free_path(dir_path: Path, filename: str) -> Path:
120
+ p = dir_path / filename
121
+ if not p.exists():
122
+ return p
123
+ stem = p.stem
124
+ suffix = p.suffix
125
+ n = 2
126
+ while True:
127
+ candidate = dir_path / f"{stem} ({n}){suffix}"
128
+ if not candidate.exists():
129
+ return candidate
130
+ n += 1
131
+
132
+ class DownloadTask:
133
+ def __init__(self, book_id: str, title: str, url: str):
134
+ self.book_id = book_id
135
+ self.title = title
136
+ self.url = url
137
+ self.progress = 0.0 # 0 to 1.0
138
+ self.status = "Pending" # Pending, Downloading, Completed, Failed, Cancelled
139
+ self.error = None
140
+ self.file_path = None
141
+ self._active_task: Optional[asyncio.Task] = None
142
+
143
+ class DownloadManager:
144
+ def __init__(self, api_client: APIClient, download_dir: Optional[Path] = None):
145
+ self.api_client = api_client
146
+ # Default to the app's library folder ("~/ComicCatcher" unless configured).
147
+ # UI layer can also override by passing download_dir explicitly.
148
+ if download_dir is None:
149
+ self.download_dir = Path.home() / "ComicCatcher"
150
+ else:
151
+ self.download_dir = Path(download_dir)
152
+ self.tasks: Dict[str, DownloadTask] = {}
153
+ self._callbacks: List[Callable] = []
154
+ self._queue = asyncio.Queue()
155
+ self._worker_task: Optional[asyncio.Task] = None
156
+
157
+ def _ensure_worker(self):
158
+ if self._worker_task is None or self._worker_task.done():
159
+ self._worker_task = asyncio.create_task(self._queue_worker())
160
+
161
+ async def _queue_worker(self):
162
+ while True:
163
+ task = await self._queue.get()
164
+ try:
165
+ await self._download_worker(task)
166
+ except Exception as e:
167
+ logger.error(f"Error in download worker: {e}")
168
+ finally:
169
+ self._queue.task_done()
170
+
171
+ def set_callback(self, callback: Callable):
172
+ """Deprecated: use add_callback instead."""
173
+ self.add_callback(callback)
174
+
175
+ def add_callback(self, callback: Callable):
176
+ if callback not in self._callbacks:
177
+ self._callbacks.append(callback)
178
+
179
+ def remove_callback(self, callback: Callable):
180
+ if callback in self._callbacks:
181
+ self._callbacks.remove(callback)
182
+
183
+ async def start_download(self, book_id: str, title: str, url: str):
184
+ # Ensure we have a unique ID for the task list
185
+ if not book_id:
186
+ # Fallback to a hash of the URL if identifier is missing
187
+ import hashlib
188
+ book_id = hashlib.md5(url.encode()).hexdigest()
189
+
190
+ if book_id in self.tasks and self.tasks[book_id].status in ("Completed", "Downloading", "Pending"):
191
+ logger.info(f"Book {title} already queued or downloading.")
192
+ return
193
+
194
+ task = DownloadTask(book_id, title, url)
195
+ task.status = "Pending"
196
+ self.tasks[book_id] = task
197
+ self._notify()
198
+
199
+ await self._queue.put(task)
200
+ self._ensure_worker()
201
+
202
+ def cancel_download(self, book_id: str):
203
+ if book_id in self.tasks:
204
+ task = self.tasks[book_id]
205
+ # Since we are using a sequential queue, we need to know if the task is currently downloading
206
+ if task.status == "Downloading":
207
+ if hasattr(task, "_active_task") and task._active_task and not task._active_task.done():
208
+ task._active_task.cancel()
209
+ elif task.status == "Pending":
210
+ # Removing from queue is hard in asyncio.Queue, we just mark it as cancelled
211
+ pass
212
+
213
+ task.status = "Cancelled"
214
+ self._notify()
215
+
216
+ async def _download_worker(self, task: DownloadTask):
217
+ if task.status == "Cancelled":
218
+ return
219
+
220
+ task.status = "Downloading"
221
+ task._active_task = asyncio.current_task()
222
+ self._notify()
223
+
224
+ try:
225
+ self.download_dir.mkdir(parents=True, exist_ok=True)
226
+ except Exception:
227
+ # If CWD isn't usable for some reason, fall back to current CWD anyway.
228
+ self.download_dir = Path.cwd()
229
+
230
+ # Provisional path shown in UI until response headers arrive.
231
+ task.file_path = _collision_free_path(self.download_dir, _sanitize_filename(task.title))
232
+
233
+ try:
234
+ async with self.api_client.client.stream("GET", task.url) as response:
235
+ if response.status_code != 200:
236
+ raise Exception(f"Server returned status {response.status_code}")
237
+
238
+ # Choose filename like browsers do: Content-Disposition first, then URL leaf, then title.
239
+ cd = response.headers.get("Content-Disposition") or response.headers.get("content-disposition") or ""
240
+ mime = response.headers.get("Content-Type") or response.headers.get("content-type")
241
+
242
+ suggested = _filename_from_content_disposition(cd) or _filename_from_url(task.url) or task.title
243
+ task.file_path = _collision_free_path(self.download_dir, _sanitize_filename(suggested, mime))
244
+ self._notify()
245
+
246
+ total_bytes = int(response.headers.get("Content-Length", 0))
247
+ downloaded_bytes = 0
248
+
249
+ with open(task.file_path, "wb") as f:
250
+ async for chunk in response.aiter_bytes():
251
+ f.write(chunk)
252
+ downloaded_bytes += len(chunk)
253
+ if total_bytes > 0:
254
+ task.progress = downloaded_bytes / total_bytes
255
+ self._notify()
256
+
257
+ # yield control to event loop to allow cancellation
258
+ await asyncio.sleep(0)
259
+
260
+ task.status = "Completed"
261
+ task.progress = 1.0
262
+ logger.info(f"Download completed: {task.title} -> {task.file_path}")
263
+ except asyncio.CancelledError:
264
+ task.status = "Cancelled"
265
+ logger.info(f"Download cancelled: {task.title}")
266
+ if task.file_path and task.file_path.exists():
267
+ try: os.remove(task.file_path)
268
+ except: pass
269
+ except Exception as e:
270
+ task.status = "Failed"
271
+ task.error = str(e)
272
+ logger.error(f"Download failed for {task.title}: {e}")
273
+ if task.file_path and task.file_path.exists():
274
+ try: os.remove(task.file_path)
275
+ except: pass
276
+
277
+ self._notify()
278
+
279
+ def _notify(self):
280
+ for cb in self._callbacks:
281
+ try:
282
+ cb()
283
+ except Exception as e:
284
+ logger.error(f"Error calling download callback: {e}")
285
+
286
+ def get_task(self, book_id: str) -> Optional[DownloadTask]:
287
+ return self.tasks.get(book_id)
288
+
289
+ def remove_task(self, book_id: str):
290
+ if book_id in self.tasks:
291
+ task = self.tasks[book_id]
292
+ if task.status == "Downloading":
293
+ self.cancel_download(book_id)
294
+ del self.tasks[book_id]
295
+ self._notify()