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.
- comiccatcher/__init__.py +38 -0
- comiccatcher/_build_info.py +2 -0
- comiccatcher/api/__init__.py +0 -0
- comiccatcher/api/client.py +73 -0
- comiccatcher/api/download_manager.py +295 -0
- comiccatcher/api/feed_reconciler.py +596 -0
- comiccatcher/api/image_manager.py +178 -0
- comiccatcher/api/library_scanner.py +120 -0
- comiccatcher/api/local_db.py +323 -0
- comiccatcher/api/opds_v2.py +133 -0
- comiccatcher/api/progression.py +94 -0
- comiccatcher/config.py +313 -0
- comiccatcher/logger.py +44 -0
- comiccatcher/main.py +198 -0
- comiccatcher/models/__init__.py +0 -0
- comiccatcher/models/feed.py +20 -0
- comiccatcher/models/feed_page.py +134 -0
- comiccatcher/models/opds.py +186 -0
- comiccatcher/models/opds_auth.py +19 -0
- comiccatcher/resources/app.png +0 -0
- comiccatcher/resources/app_128.png +0 -0
- comiccatcher/resources/app_256.png +0 -0
- comiccatcher/resources/app_32.png +0 -0
- comiccatcher/resources/app_64.png +0 -0
- comiccatcher/resources/icons/action_delete.svg +6 -0
- comiccatcher/resources/icons/action_read.svg +4 -0
- comiccatcher/resources/icons/action_unread.svg +3 -0
- comiccatcher/resources/icons/back.svg +1 -0
- comiccatcher/resources/icons/book.svg +1 -0
- comiccatcher/resources/icons/chevron_down.svg +3 -0
- comiccatcher/resources/icons/chevron_left.svg +3 -0
- comiccatcher/resources/icons/chevron_right.svg +3 -0
- comiccatcher/resources/icons/chevrons_left.svg +4 -0
- comiccatcher/resources/icons/chevrons_right.svg +4 -0
- comiccatcher/resources/icons/close.svg +1 -0
- comiccatcher/resources/icons/download.svg +1 -0
- comiccatcher/resources/icons/eye.svg +4 -0
- comiccatcher/resources/icons/feeds.svg +1 -0
- comiccatcher/resources/icons/filter.svg +3 -0
- comiccatcher/resources/icons/focus_series.svg +1 -0
- comiccatcher/resources/icons/focus_title.svg +1 -0
- comiccatcher/resources/icons/folder.svg +1 -0
- comiccatcher/resources/icons/fullscreen.svg +1 -0
- comiccatcher/resources/icons/globe.svg +1 -0
- comiccatcher/resources/icons/group_by.svg +11 -0
- comiccatcher/resources/icons/group_misc.svg +1 -0
- comiccatcher/resources/icons/home.svg +1 -0
- comiccatcher/resources/icons/label.svg +1 -0
- comiccatcher/resources/icons/library.svg +7 -0
- comiccatcher/resources/icons/minimize.svg +1 -0
- comiccatcher/resources/icons/paging.svg +4 -0
- comiccatcher/resources/icons/plus.svg +4 -0
- comiccatcher/resources/icons/refresh.svg +1 -0
- comiccatcher/resources/icons/scrolling.svg +5 -0
- comiccatcher/resources/icons/search.svg +1 -0
- comiccatcher/resources/icons/select.svg +4 -0
- comiccatcher/resources/icons/settings.svg +11 -0
- comiccatcher/resources/icons/sort_added.svg +1 -0
- comiccatcher/resources/icons/sort_alpha.svg +1 -0
- comiccatcher/resources/icons/sort_asc.svg +1 -0
- comiccatcher/resources/icons/sort_date.svg +1 -0
- comiccatcher/resources/icons/sort_desc.svg +1 -0
- comiccatcher/resources/icons/view_file.svg +1 -0
- comiccatcher/resources/icons/view_grid.svg +1 -0
- comiccatcher/resources/icons/view_group.svg +11 -0
- comiccatcher/ui/__init__.py +0 -0
- comiccatcher/ui/app_layout.py +1451 -0
- comiccatcher/ui/base_reader.py +1749 -0
- comiccatcher/ui/components/__init__.py +0 -0
- comiccatcher/ui/components/auth_dialog.py +121 -0
- comiccatcher/ui/components/base_card_delegate.py +372 -0
- comiccatcher/ui/components/base_ribbon.py +111 -0
- comiccatcher/ui/components/collapsible_section.py +137 -0
- comiccatcher/ui/components/feed_browser_model.py +279 -0
- comiccatcher/ui/components/feed_card_delegate.py +124 -0
- comiccatcher/ui/components/library_card_delegate.py +67 -0
- comiccatcher/ui/components/loading_spinner.py +62 -0
- comiccatcher/ui/components/mini_detail_popover.py +516 -0
- comiccatcher/ui/components/paging_control.py +129 -0
- comiccatcher/ui/components/popover_mixin.py +87 -0
- comiccatcher/ui/components/section_header.py +89 -0
- comiccatcher/ui/debug_overlay.py +157 -0
- comiccatcher/ui/flow_layout.py +97 -0
- comiccatcher/ui/image_data.py +43 -0
- comiccatcher/ui/image_utils.py +75 -0
- comiccatcher/ui/local_archive.py +68 -0
- comiccatcher/ui/local_comicbox.py +273 -0
- comiccatcher/ui/reader_logic.py +140 -0
- comiccatcher/ui/theme_manager.py +1164 -0
- comiccatcher/ui/utils.py +75 -0
- comiccatcher/ui/view_helpers.py +126 -0
- comiccatcher/ui/views/__init__.py +0 -0
- comiccatcher/ui/views/base_browser.py +303 -0
- comiccatcher/ui/views/base_detail.py +397 -0
- comiccatcher/ui/views/base_feed_subview.py +192 -0
- comiccatcher/ui/views/downloads.py +230 -0
- comiccatcher/ui/views/feed_browser.py +1003 -0
- comiccatcher/ui/views/feed_detail.py +782 -0
- comiccatcher/ui/views/feed_list.py +182 -0
- comiccatcher/ui/views/feed_management.py +579 -0
- comiccatcher/ui/views/feed_reader.py +207 -0
- comiccatcher/ui/views/local_detail.py +294 -0
- comiccatcher/ui/views/local_library.py +1621 -0
- comiccatcher/ui/views/local_reader.py +204 -0
- comiccatcher/ui/views/paged_feed_view.py +290 -0
- comiccatcher/ui/views/scrolled_feed_view.py +903 -0
- comiccatcher/ui/views/search_root.py +200 -0
- comiccatcher/ui/views/settings.py +261 -0
- comiccatcher-0.1.0a2.dist-info/METADATA +484 -0
- comiccatcher-0.1.0a2.dist-info/RECORD +114 -0
- comiccatcher-0.1.0a2.dist-info/WHEEL +5 -0
- comiccatcher-0.1.0a2.dist-info/entry_points.txt +2 -0
- comiccatcher-0.1.0a2.dist-info/licenses/LICENSE +201 -0
- comiccatcher-0.1.0a2.dist-info/top_level.txt +1 -0
comiccatcher/__init__.py
ADDED
|
@@ -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__
|
|
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()
|