redfetch 1.3.0__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.
- redfetch/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
redfetch/download.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# standard
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
import hashlib
|
|
6
|
+
from zipfile import ZipFile, is_zipfile
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
# third-party
|
|
10
|
+
import httpx
|
|
11
|
+
import aiofiles
|
|
12
|
+
from tenacity import (
|
|
13
|
+
retry,
|
|
14
|
+
stop_after_attempt,
|
|
15
|
+
wait_exponential,
|
|
16
|
+
retry_if_exception_type,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# local
|
|
20
|
+
from redfetch import config
|
|
21
|
+
from redfetch.utils import is_safe_path
|
|
22
|
+
|
|
23
|
+
# ZIP safety constants
|
|
24
|
+
MAX_UNCOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024 # 2GB limit
|
|
25
|
+
MAX_FILES_PER_ZIP = 60000 # Safety cap to avoid zipbombs
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def download_install_target_async(
|
|
29
|
+
*,
|
|
30
|
+
client: httpx.AsyncClient,
|
|
31
|
+
resource_id: str,
|
|
32
|
+
download_url: str,
|
|
33
|
+
filename: str,
|
|
34
|
+
file_hash: str | None,
|
|
35
|
+
folder_path: str,
|
|
36
|
+
should_flatten: bool = False,
|
|
37
|
+
protected_files: list[str] | None = None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
print(f"Starting download: {filename} (ID: {resource_id})", flush=True)
|
|
41
|
+
file_path = os.path.join(folder_path, filename)
|
|
42
|
+
ok = await download_file_async(client, download_url, file_path, file_hash)
|
|
43
|
+
if not ok:
|
|
44
|
+
print(f"Download failed for resource {resource_id}.")
|
|
45
|
+
return False
|
|
46
|
+
if is_zipfile(file_path):
|
|
47
|
+
extracted = await asyncio.to_thread(
|
|
48
|
+
extract_and_discard_zip,
|
|
49
|
+
file_path,
|
|
50
|
+
folder_path,
|
|
51
|
+
resource_id,
|
|
52
|
+
should_flatten,
|
|
53
|
+
protected_files,
|
|
54
|
+
)
|
|
55
|
+
if not extracted:
|
|
56
|
+
print(f"Extraction failed for resource {resource_id}.")
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
except httpx.HTTPError as e:
|
|
60
|
+
print(f"Failed to fetch or download resource {resource_id}: {str(e)}")
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@retry(
|
|
65
|
+
stop=stop_after_attempt(3),
|
|
66
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=4),
|
|
67
|
+
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError, httpx.HTTPStatusError)),
|
|
68
|
+
reraise=True,
|
|
69
|
+
)
|
|
70
|
+
async def download_file_async(
|
|
71
|
+
client: httpx.AsyncClient,
|
|
72
|
+
download_url: str,
|
|
73
|
+
file_path: str,
|
|
74
|
+
expected_md5: str | None = None,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Download a file to disk using an async flow:
|
|
78
|
+
1. open HTTP stream
|
|
79
|
+
2. stream body into a temp file with async for / await
|
|
80
|
+
3. optionally verify MD5
|
|
81
|
+
4. atomically move temp file into place
|
|
82
|
+
"""
|
|
83
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
84
|
+
|
|
85
|
+
hasher = hashlib.md5() if expected_md5 else None
|
|
86
|
+
tmp_path = None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
async with client.stream("GET", download_url, timeout=60.0, follow_redirects=True) as resp:
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
_ensure_disk_space(resp, file_path)
|
|
92
|
+
|
|
93
|
+
# Core async loop lives in a small helper
|
|
94
|
+
tmp_path = await _stream_to_tempfile(resp, file_path, hasher)
|
|
95
|
+
|
|
96
|
+
if expected_md5 and hasher:
|
|
97
|
+
if not _hash_matches(hasher, expected_md5, file_path):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
if tmp_path is None:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
os.replace(tmp_path, file_path)
|
|
104
|
+
print(f"Downloaded file {file_path}")
|
|
105
|
+
return True
|
|
106
|
+
finally:
|
|
107
|
+
try:
|
|
108
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
109
|
+
os.remove(tmp_path)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ensure_disk_space(resp, file_path: str) -> None:
|
|
115
|
+
"""Disk space guard using Content-Length when available."""
|
|
116
|
+
content_length = resp.headers.get("Content-Length")
|
|
117
|
+
if content_length is None or not content_length.isdigit():
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
expected_size = int(content_length)
|
|
121
|
+
target_dir = os.path.dirname(file_path)
|
|
122
|
+
free_bytes = shutil.disk_usage(target_dir).free
|
|
123
|
+
# Require a 100MB cushion
|
|
124
|
+
if free_bytes < expected_size + 100 * 1024 * 1024:
|
|
125
|
+
raise OSError(f"Insufficient disk space for download: need ~{expected_size} bytes")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _stream_to_tempfile(resp, file_path: str, hasher) -> str:
|
|
129
|
+
"""Stream HTTP body into a temp file."""
|
|
130
|
+
tmp_dir = os.path.dirname(file_path)
|
|
131
|
+
tmp_path = None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
async with aiofiles.tempfile.NamedTemporaryFile("wb", delete=False, dir=tmp_dir) as tmp:
|
|
135
|
+
tmp_path = tmp.name
|
|
136
|
+
|
|
137
|
+
async for chunk in resp.aiter_bytes(chunk_size=262_144):
|
|
138
|
+
if not chunk:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if hasher:
|
|
142
|
+
hasher.update(chunk)
|
|
143
|
+
|
|
144
|
+
await tmp.write(chunk)
|
|
145
|
+
|
|
146
|
+
return tmp_path
|
|
147
|
+
except Exception:
|
|
148
|
+
# Clean up any partially written temp file on error
|
|
149
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
150
|
+
os.remove(tmp_path)
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _hash_matches(hasher, expected_md5: str, file_path: str) -> bool:
|
|
155
|
+
"""Return True when the computed MD5 matches *expected_md5*."""
|
|
156
|
+
actual = hasher.hexdigest().lower()
|
|
157
|
+
expected = expected_md5.strip().lower()
|
|
158
|
+
|
|
159
|
+
if actual == expected:
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
print(f"MD5 mismatch for {os.path.basename(file_path)}")
|
|
163
|
+
print(f" Expected: {expected}")
|
|
164
|
+
print(f" Got: {actual}")
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
#
|
|
169
|
+
# zip functions
|
|
170
|
+
#
|
|
171
|
+
|
|
172
|
+
def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=False, protected_files=None) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
zip_size = os.path.getsize(zip_path)
|
|
175
|
+
if zip_size > MAX_UNCOMPRESSED_SIZE:
|
|
176
|
+
print(f"ZIP file {zip_path} exceeds the 2GB size limit. Extraction aborted.")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
with ZipFile(zip_path, 'r') as zip_ref:
|
|
180
|
+
try:
|
|
181
|
+
bad_member = zip_ref.testzip()
|
|
182
|
+
if bad_member is not None:
|
|
183
|
+
print(f"ZIP integrity check failed at member: {bad_member}. Extraction aborted.")
|
|
184
|
+
return False
|
|
185
|
+
except Exception as e:
|
|
186
|
+
print(f"ZIP integrity check failed: {e}. Extraction aborted.")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
infos = zip_ref.infolist()
|
|
190
|
+
if len(infos) > MAX_FILES_PER_ZIP:
|
|
191
|
+
print(f"ZIP has too many files ({len(infos)} > {MAX_FILES_PER_ZIP}). Extraction aborted.")
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
total_uncompressed_size = sum(zinfo.file_size for zinfo in infos)
|
|
195
|
+
if total_uncompressed_size > MAX_UNCOMPRESSED_SIZE:
|
|
196
|
+
print(f"Total uncompressed size {total_uncompressed_size} exceeds the 2GB limit. Extraction aborted.")
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
free_bytes = shutil.disk_usage(extract_to).free
|
|
201
|
+
if free_bytes < total_uncompressed_size + 500 * 1024 * 1024:
|
|
202
|
+
print("Insufficient disk space for extraction. Extraction aborted.")
|
|
203
|
+
return False
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
if protected_files is None:
|
|
208
|
+
protected_files = config.settings.from_env(config.settings.ENV).PROTECTED_FILES_BY_RESOURCE.get(resource_id, [])
|
|
209
|
+
if should_flatten:
|
|
210
|
+
extract_flattened(zip_ref, extract_to, protected_files)
|
|
211
|
+
else:
|
|
212
|
+
extract_with_structure(zip_ref, extract_to, protected_files)
|
|
213
|
+
return True
|
|
214
|
+
finally:
|
|
215
|
+
delete_zip_file(zip_path)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def extract_flattened(zip_ref, extract_to, protected_files):
|
|
219
|
+
print(f"Flattening extraction to {extract_to}")
|
|
220
|
+
protected_files_lower = {f.lower() for f in protected_files}
|
|
221
|
+
for member in zip_ref.infolist():
|
|
222
|
+
filename = os.path.basename(member.filename)
|
|
223
|
+
if not filename:
|
|
224
|
+
continue
|
|
225
|
+
target_path = os.path.join(extract_to, filename)
|
|
226
|
+
normalized_path = os.path.normpath(target_path)
|
|
227
|
+
if is_protected(filename, normalized_path, protected_files, protected_files_lower):
|
|
228
|
+
print(f"Skipping protected file {filename}")
|
|
229
|
+
continue
|
|
230
|
+
if is_safe_path(extract_to, normalized_path):
|
|
231
|
+
extract_zip_member(zip_ref, member, normalized_path)
|
|
232
|
+
else:
|
|
233
|
+
print(f"Skipping unsafe file {member.filename}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def extract_with_structure(zip_ref, extract_to, protected_files):
|
|
237
|
+
print(f"Extracting with structure to {extract_to}")
|
|
238
|
+
protected_files_lower = {f.lower() for f in protected_files}
|
|
239
|
+
for member in zip_ref.infolist():
|
|
240
|
+
target_path = os.path.join(extract_to, member.filename)
|
|
241
|
+
normalized_path = os.path.normpath(target_path)
|
|
242
|
+
if not is_safe_path(extract_to, normalized_path):
|
|
243
|
+
print(f"Skipping unsafe file {member.filename}")
|
|
244
|
+
continue
|
|
245
|
+
if is_protected(os.path.basename(member.filename), normalized_path, protected_files, protected_files_lower):
|
|
246
|
+
print(f"Skipping protected file {member.filename}")
|
|
247
|
+
continue
|
|
248
|
+
if member.is_dir():
|
|
249
|
+
os.makedirs(normalized_path, exist_ok=True)
|
|
250
|
+
continue
|
|
251
|
+
extract_zip_member(zip_ref, member, normalized_path)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def extract_zip_member(zip_ref, member, target_path):
|
|
255
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
256
|
+
max_retries = 3
|
|
257
|
+
retry_delay = 10 # seconds
|
|
258
|
+
|
|
259
|
+
for attempt in range(max_retries):
|
|
260
|
+
try:
|
|
261
|
+
with zip_ref.open(member) as source, open(target_path, 'wb') as target:
|
|
262
|
+
shutil.copyfileobj(source, target)
|
|
263
|
+
return # Successful extraction, exit the function
|
|
264
|
+
except PermissionError:
|
|
265
|
+
file_name = os.path.basename(target_path)
|
|
266
|
+
folder_path = os.path.dirname(target_path)
|
|
267
|
+
|
|
268
|
+
error_msg = [
|
|
269
|
+
f"\nPermission Error: Unable to extract {file_name}",
|
|
270
|
+
"\nThis could be because:",
|
|
271
|
+
"1. The file is currently in use by another program (e.g., MacroQuest, EQBCS)",
|
|
272
|
+
"2. You don't have write permissions for this location",
|
|
273
|
+
"\nPossible solutions:",
|
|
274
|
+
"1. Close all EverQuest-related programs (MacroQuest, EQBCS, etc.)",
|
|
275
|
+
f"2. Change the installation directory in settings to a location you own",
|
|
276
|
+
f"3. Manually set write permissions on: {folder_path}",
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
if attempt < max_retries - 1:
|
|
280
|
+
error_msg.append(f"\nRetrying in {retry_delay} seconds... (Attempt {attempt + 1} of {max_retries})")
|
|
281
|
+
print("\n".join(error_msg))
|
|
282
|
+
time.sleep(retry_delay)
|
|
283
|
+
else:
|
|
284
|
+
error_msg.append("\nMaximum retry attempts reached. Please resolve the permission issue and try again.")
|
|
285
|
+
print("\n".join(error_msg))
|
|
286
|
+
raise PermissionError(f"Failed to extract {file_name} after {max_retries} attempts.")
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f"Unexpected error while extracting {os.path.basename(target_path)}: {str(e)}")
|
|
289
|
+
raise
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def delete_zip_file(zip_path):
|
|
293
|
+
try:
|
|
294
|
+
os.remove(zip_path)
|
|
295
|
+
except PermissionError as e:
|
|
296
|
+
print(f"PermissionError: Unable to delete zip file {zip_path}. Error: {e}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
#
|
|
300
|
+
# utility functions
|
|
301
|
+
#
|
|
302
|
+
|
|
303
|
+
def is_protected(filename, target_path, protected_files, protected_files_lower):
|
|
304
|
+
# Overwrite protection for specified files
|
|
305
|
+
filename_lower = filename.lower()
|
|
306
|
+
if filename_lower in protected_files_lower and os.path.exists(target_path):
|
|
307
|
+
# Retrieve the original filename case for message consistency
|
|
308
|
+
protected_list = [f for f in protected_files if f.lower() == filename_lower]
|
|
309
|
+
original_filename = protected_list[0] if protected_list else filename
|
|
310
|
+
print(f"Protected {original_filename}, skipping extraction.")
|
|
311
|
+
return True
|
|
312
|
+
return False
|
redfetch/listener.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Local API for the RedGuides.com web interface."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import webbrowser
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
|
|
10
|
+
from redfetch import store
|
|
11
|
+
from redfetch import sync
|
|
12
|
+
from redfetch.special import compute_special_status
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
REDGUIDES_ORIGIN = "https://www.redguides.com"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@web.middleware
|
|
21
|
+
async def cors_middleware(request: web.Request, handler):
|
|
22
|
+
"""Simple CORS middleware allowing RedGuides origin."""
|
|
23
|
+
if request.method == "OPTIONS":
|
|
24
|
+
resp = web.Response(status=204)
|
|
25
|
+
else:
|
|
26
|
+
resp = await handler(request)
|
|
27
|
+
|
|
28
|
+
resp.headers["Access-Control-Allow-Origin"] = REDGUIDES_ORIGIN
|
|
29
|
+
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
30
|
+
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
|
31
|
+
resp.headers["Access-Control-Allow-Credentials"] = "true"
|
|
32
|
+
return resp
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _get_root_version_local_async(db_name: str, resource_id: str) -> Optional[int]:
|
|
36
|
+
"""Async helper to fetch root version_local for a tracked root install target."""
|
|
37
|
+
db_path = store.get_db_path(db_name)
|
|
38
|
+
try:
|
|
39
|
+
return await store.fetch_root_version_local(db_path, resource_id)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
if "no such table" in str(e):
|
|
42
|
+
# Table doesn't exist yet; treat as not installed
|
|
43
|
+
return None
|
|
44
|
+
log.error("Database error during health check: %s", e, exc_info=True)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def handle_health(request: web.Request) -> web.Response:
|
|
49
|
+
db_name = request.app["db_name"]
|
|
50
|
+
|
|
51
|
+
resource_id = request.query.get("resource_id")
|
|
52
|
+
remote_version_str = request.query.get("remote_version")
|
|
53
|
+
|
|
54
|
+
if resource_id and remote_version_str is not None:
|
|
55
|
+
try:
|
|
56
|
+
remote_version = int(remote_version_str)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return web.json_response({"success": False, "message": "Invalid remote_version"}, status=400)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
version_local = await _get_root_version_local_async(db_name, resource_id)
|
|
62
|
+
except Exception:
|
|
63
|
+
return web.json_response({"success": False, "message": "Database error"}, status=500)
|
|
64
|
+
|
|
65
|
+
if version_local is None:
|
|
66
|
+
return web.json_response({"action": "install"})
|
|
67
|
+
elif version_local < remote_version:
|
|
68
|
+
return web.json_response({"action": "update"})
|
|
69
|
+
else:
|
|
70
|
+
return web.json_response({"action": "re-install"})
|
|
71
|
+
|
|
72
|
+
return web.json_response({"status": "up"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def handle_download(request: web.Request) -> web.Response:
|
|
76
|
+
app = request.app
|
|
77
|
+
db_name = app["db_name"]
|
|
78
|
+
headers = app["headers"]
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
payload: Dict[str, Any] = await request.json()
|
|
82
|
+
except Exception:
|
|
83
|
+
return web.json_response({"success": False, "message": "JSON body is required."}, status=400)
|
|
84
|
+
|
|
85
|
+
resource_id = payload.get("resource_id")
|
|
86
|
+
if resource_id is None:
|
|
87
|
+
return web.json_response({"success": False, "message": "Resource ID is required."}, status=400)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
resource_id_str = str(resource_id)
|
|
91
|
+
db_path = store.get_db_path(db_name)
|
|
92
|
+
success = await sync.run_sync(db_path, headers, resource_ids=[resource_id_str])
|
|
93
|
+
if success:
|
|
94
|
+
return web.json_response({"success": True, "message": "Download completed successfully."})
|
|
95
|
+
return web.json_response({"success": False, "message": "Download failed due to internal error."}, status=500)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
log.error("Error during download: %s", e, exc_info=True)
|
|
98
|
+
return web.json_response({"success": False, "message": f"Download failed: {e}"}, status=500)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def handle_download_watched(request: web.Request) -> web.Response:
|
|
102
|
+
app = request.app
|
|
103
|
+
db_name = app["db_name"]
|
|
104
|
+
headers = app["headers"]
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
db_path = store.get_db_path(db_name)
|
|
108
|
+
success = await sync.run_sync(db_path, headers)
|
|
109
|
+
if success:
|
|
110
|
+
return web.json_response(
|
|
111
|
+
{"success": True, "message": "All watched resources downloaded successfully."}
|
|
112
|
+
)
|
|
113
|
+
return web.json_response(
|
|
114
|
+
{"success": False, "message": "Download of one or more resources failed."}, status=500
|
|
115
|
+
)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
log.error("Error during download of watched resources: %s", e, exc_info=True)
|
|
118
|
+
return web.json_response({"success": False, "message": f"Download failed: {e}"}, status=500)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def handle_reset_download_date(request: web.Request) -> web.Response:
|
|
122
|
+
app = request.app
|
|
123
|
+
db_name = app["db_name"]
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
payload: Dict[str, Any] = await request.json()
|
|
127
|
+
except Exception:
|
|
128
|
+
return web.json_response({"success": False, "message": "JSON body is required."}, status=400)
|
|
129
|
+
|
|
130
|
+
resource_id = payload.get("resource_id")
|
|
131
|
+
if not resource_id:
|
|
132
|
+
return web.json_response({"success": False, "message": "Resource ID is required."}, status=400)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
int(str(resource_id))
|
|
136
|
+
except ValueError:
|
|
137
|
+
return web.json_response({"success": False, "message": "Invalid resource ID format."}, status=400)
|
|
138
|
+
|
|
139
|
+
def _reset() -> bool:
|
|
140
|
+
try:
|
|
141
|
+
with store.get_db_connection(db_name) as conn:
|
|
142
|
+
cursor = conn.cursor()
|
|
143
|
+
store.reset_versions_for_resource(cursor, str(resource_id))
|
|
144
|
+
conn.commit()
|
|
145
|
+
return True
|
|
146
|
+
except Exception as e:
|
|
147
|
+
log.error("Error during resetting download date: %s", e, exc_info=True)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
ok = await asyncio.to_thread(_reset)
|
|
151
|
+
if ok:
|
|
152
|
+
return web.json_response({"success": True, "message": "Download date reset successfully."})
|
|
153
|
+
return web.json_response({"success": False, "message": "Reset failed."}, status=500)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def handle_category_map(request: web.Request) -> web.Response:
|
|
157
|
+
category_map = request.app["category_map"]
|
|
158
|
+
return web.json_response(list(category_map.keys()))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def handle_special_resource_ids(request: web.Request) -> web.Response:
|
|
162
|
+
status = compute_special_status(None)
|
|
163
|
+
special_resource_ids = [int(rid) for rid, info in status.items() if info["is_special"]]
|
|
164
|
+
return web.json_response(special_resource_ids)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def create_app(
|
|
168
|
+
settings,
|
|
169
|
+
db_name: str,
|
|
170
|
+
headers: dict,
|
|
171
|
+
category_map,
|
|
172
|
+
) -> web.Application:
|
|
173
|
+
"""Create the aiohttp application for the RedGuides interface."""
|
|
174
|
+
app = web.Application(middlewares=[cors_middleware])
|
|
175
|
+
|
|
176
|
+
app["settings"] = settings
|
|
177
|
+
app["db_name"] = db_name
|
|
178
|
+
app["headers"] = headers
|
|
179
|
+
app["category_map"] = category_map
|
|
180
|
+
|
|
181
|
+
app.router.add_get("/health", handle_health)
|
|
182
|
+
app.router.add_post("/download", handle_download)
|
|
183
|
+
app.router.add_post("/download-watched", handle_download_watched)
|
|
184
|
+
app.router.add_post("/reset-download-date", handle_reset_download_date)
|
|
185
|
+
app.router.add_get("/category-map", handle_category_map)
|
|
186
|
+
app.router.add_get("/special-resource-ids", handle_special_resource_ids)
|
|
187
|
+
|
|
188
|
+
return app
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def run_server_async(
|
|
192
|
+
settings,
|
|
193
|
+
db_name: str,
|
|
194
|
+
headers: dict,
|
|
195
|
+
category_map,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Run the interface server until cancelled."""
|
|
198
|
+
app = await create_app(settings, db_name, headers, category_map)
|
|
199
|
+
|
|
200
|
+
runner = web.AppRunner(app)
|
|
201
|
+
await runner.setup()
|
|
202
|
+
site = web.TCPSite(runner, "127.0.0.1", 7734)
|
|
203
|
+
await site.start()
|
|
204
|
+
|
|
205
|
+
webbrowser.open_new("https://www.redguides.com/cookie/set_marker.php")
|
|
206
|
+
log.info("Server starting. Browse resources on https://www.redguides.com/community/resources")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Wait indefinitely until the task is cancelled by the caller (CLI or TUI).
|
|
210
|
+
await asyncio.Event().wait()
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
log.info("Server task cancelled, shutting down...")
|
|
213
|
+
finally:
|
|
214
|
+
await runner.cleanup()
|
|
215
|
+
log.info("Server stopped.")
|
|
216
|
+
|