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/special.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""For handling special resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, Iterable, Set, TypedDict
|
|
7
|
+
|
|
8
|
+
from redfetch import config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpecialResourceInfo(TypedDict):
|
|
12
|
+
is_special: bool
|
|
13
|
+
is_dependency: bool
|
|
14
|
+
parent_ids: Set[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SpecialIndexes:
|
|
19
|
+
opted_in_specials: Set[str]
|
|
20
|
+
# Map dependency_id -> set(parent_ids) where parent is opted-in and dependency is opted-in
|
|
21
|
+
dependency_parents: Dict[str, Set[str]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_special_resources() -> Dict[str, dict]:
|
|
25
|
+
"""Return the SPECIAL_RESOURCES mapping for the current environment."""
|
|
26
|
+
return config.settings.from_env(config.settings.ENV).SPECIAL_RESOURCES
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_indexes(special_resources: Dict[str, dict]) -> SpecialIndexes:
|
|
30
|
+
opted_in_specials: Set[str] = set()
|
|
31
|
+
dependency_parents: Dict[str, Set[str]] = {}
|
|
32
|
+
|
|
33
|
+
for parent_id, parent_details in special_resources.items():
|
|
34
|
+
if not parent_details.get('opt_in', False):
|
|
35
|
+
continue
|
|
36
|
+
opted_in_specials.add(parent_id)
|
|
37
|
+
for dep_id, dep_details in parent_details.get('dependencies', {}).items():
|
|
38
|
+
if dep_details and dep_details.get('opt_in', False):
|
|
39
|
+
dependency_parents.setdefault(dep_id, set()).add(parent_id)
|
|
40
|
+
|
|
41
|
+
return SpecialIndexes(opted_in_specials=opted_in_specials, dependency_parents=dependency_parents)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_resource_opted_in(resource_id: str) -> bool:
|
|
45
|
+
"""Return True if the given resource is an opted-in special resource."""
|
|
46
|
+
special_resources = _get_special_resources()
|
|
47
|
+
details = special_resources.get(resource_id)
|
|
48
|
+
return bool(details and details.get('opt_in', False))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compute_special_status(resource_ids: Iterable[str] | None = None) -> Dict[str, SpecialResourceInfo]:
|
|
52
|
+
"""Compute special/dependency status for IDs (or all opted-in if None)."""
|
|
53
|
+
special_resources = _get_special_resources()
|
|
54
|
+
indexes = _build_indexes(special_resources)
|
|
55
|
+
|
|
56
|
+
# Determine the candidate ids to include
|
|
57
|
+
if resource_ids is None:
|
|
58
|
+
candidate_ids: Set[str] = set(indexes.opted_in_specials) | set(indexes.dependency_parents.keys())
|
|
59
|
+
else:
|
|
60
|
+
candidate_ids = set(resource_ids)
|
|
61
|
+
# For each provided parent id, if opted-in, include its opted-in dependencies
|
|
62
|
+
for rid in list(candidate_ids):
|
|
63
|
+
parent_details = special_resources.get(rid)
|
|
64
|
+
if parent_details and parent_details.get('opt_in', False):
|
|
65
|
+
for dep_id, dep_details in parent_details.get('dependencies', {}).items():
|
|
66
|
+
if dep_details and dep_details.get('opt_in', False):
|
|
67
|
+
candidate_ids.add(dep_id)
|
|
68
|
+
|
|
69
|
+
status: Dict[str, SpecialResourceInfo] = {}
|
|
70
|
+
for rid in candidate_ids:
|
|
71
|
+
is_special = rid in indexes.opted_in_specials
|
|
72
|
+
parent_ids = indexes.dependency_parents.get(rid, set())
|
|
73
|
+
is_dependency = bool(parent_ids)
|
|
74
|
+
status[rid] = SpecialResourceInfo(
|
|
75
|
+
is_special=is_special,
|
|
76
|
+
is_dependency=is_dependency,
|
|
77
|
+
parent_ids=set(parent_ids),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return status
|
|
81
|
+
|
redfetch/store.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
import aiosqlite
|
|
9
|
+
|
|
10
|
+
from redfetch import config
|
|
11
|
+
from redfetch import meta
|
|
12
|
+
from redfetch.sync_types import (
|
|
13
|
+
DesiredInstallTarget,
|
|
14
|
+
DesiredSet,
|
|
15
|
+
ExecutionPlan,
|
|
16
|
+
ExecutionResult,
|
|
17
|
+
LocalInstallState,
|
|
18
|
+
LocalSnapshot,
|
|
19
|
+
PlannedAction,
|
|
20
|
+
RemoteResourceState,
|
|
21
|
+
RemoteSnapshot,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SCHEMA_VERSION = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_cache_dir() -> str:
|
|
29
|
+
base = getattr(config, "config_dir", None) or os.getenv("REDFETCH_CONFIG_DIR")
|
|
30
|
+
if not base:
|
|
31
|
+
base = os.getcwd()
|
|
32
|
+
cache_dir = os.path.join(base, ".cache")
|
|
33
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
34
|
+
return cache_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_db_path(db_name: str) -> str:
|
|
38
|
+
return os.path.join(_get_cache_dir(), db_name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_db_connection(db_name: str):
|
|
42
|
+
db_path = get_db_path(db_name)
|
|
43
|
+
conn = sqlite3.connect(db_path, timeout=30.0, check_same_thread=False, isolation_level=None)
|
|
44
|
+
try:
|
|
45
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
46
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
47
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return conn
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def initialize_db(db_name: str):
|
|
54
|
+
db_path = get_db_path(db_name)
|
|
55
|
+
with sqlite3.connect(db_path, timeout=30.0) as conn:
|
|
56
|
+
cursor = conn.cursor()
|
|
57
|
+
initialize_schema(cursor)
|
|
58
|
+
conn.commit()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ensure_metadata(cursor) -> None:
|
|
62
|
+
try:
|
|
63
|
+
cursor.execute("SELECT schema_version FROM metadata WHERE id = 1")
|
|
64
|
+
row = cursor.fetchone()
|
|
65
|
+
if row and row[0] is not None and int(row[0]) >= SCHEMA_VERSION:
|
|
66
|
+
return
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
_reset_sync_schema(cursor)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ensure_downloads_table(cursor) -> None:
|
|
73
|
+
cursor.execute(
|
|
74
|
+
"""
|
|
75
|
+
CREATE TABLE IF NOT EXISTS downloads (
|
|
76
|
+
id INTEGER PRIMARY KEY,
|
|
77
|
+
target_key TEXT UNIQUE,
|
|
78
|
+
resource_id INTEGER NOT NULL,
|
|
79
|
+
parent_id INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
parent_target_key TEXT,
|
|
81
|
+
root_resource_id INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
target_kind TEXT NOT NULL DEFAULT 'root',
|
|
83
|
+
category_id INTEGER,
|
|
84
|
+
title TEXT,
|
|
85
|
+
version_remote INTEGER,
|
|
86
|
+
version_local INTEGER,
|
|
87
|
+
resolved_path TEXT,
|
|
88
|
+
subfolder TEXT,
|
|
89
|
+
flatten INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
protected_files TEXT NOT NULL DEFAULT '[]',
|
|
91
|
+
remote_status TEXT,
|
|
92
|
+
is_special INTEGER NOT NULL DEFAULT 0,
|
|
93
|
+
is_watching INTEGER NOT NULL DEFAULT 0,
|
|
94
|
+
is_licensed INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
is_explicit INTEGER NOT NULL DEFAULT 0,
|
|
96
|
+
is_dependency INTEGER NOT NULL DEFAULT 0
|
|
97
|
+
)
|
|
98
|
+
"""
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_indexes(cursor) -> None:
|
|
103
|
+
cursor.execute(
|
|
104
|
+
"CREATE INDEX IF NOT EXISTS idx_downloads_resource_id ON downloads(resource_id)"
|
|
105
|
+
)
|
|
106
|
+
cursor.execute(
|
|
107
|
+
"CREATE INDEX IF NOT EXISTS idx_downloads_root_resource_id ON downloads(root_resource_id)"
|
|
108
|
+
)
|
|
109
|
+
cursor.execute(
|
|
110
|
+
"CREATE INDEX IF NOT EXISTS idx_downloads_parent_target_key ON downloads(parent_target_key)"
|
|
111
|
+
)
|
|
112
|
+
cursor.execute(
|
|
113
|
+
"CREATE INDEX IF NOT EXISTS idx_downloads_parent_id ON downloads(parent_id)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ensure_navmesh_tables(cursor) -> None:
|
|
118
|
+
cursor.execute(
|
|
119
|
+
"""
|
|
120
|
+
CREATE TABLE IF NOT EXISTS navmesh_files (
|
|
121
|
+
filename TEXT PRIMARY KEY,
|
|
122
|
+
md5_hash TEXT NOT NULL,
|
|
123
|
+
file_size INTEGER NOT NULL,
|
|
124
|
+
mtime_ns INTEGER NOT NULL
|
|
125
|
+
)
|
|
126
|
+
"""
|
|
127
|
+
)
|
|
128
|
+
cursor.execute(
|
|
129
|
+
"""
|
|
130
|
+
CREATE TABLE IF NOT EXISTS navmesh_cache (
|
|
131
|
+
env TEXT PRIMARY KEY,
|
|
132
|
+
etag TEXT,
|
|
133
|
+
last_modified TEXT,
|
|
134
|
+
manifest_json TEXT
|
|
135
|
+
)
|
|
136
|
+
"""
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _reset_sync_schema(cursor) -> None:
|
|
141
|
+
cursor.execute("DROP TABLE IF EXISTS downloads")
|
|
142
|
+
cursor.execute("DROP TABLE IF EXISTS resources")
|
|
143
|
+
cursor.execute("DROP TABLE IF EXISTS dependencies")
|
|
144
|
+
cursor.execute("DROP TABLE IF EXISTS metadata")
|
|
145
|
+
cursor.execute(
|
|
146
|
+
"""
|
|
147
|
+
CREATE TABLE metadata (
|
|
148
|
+
id INTEGER PRIMARY KEY,
|
|
149
|
+
schema_version INTEGER
|
|
150
|
+
)
|
|
151
|
+
"""
|
|
152
|
+
)
|
|
153
|
+
cursor.execute(
|
|
154
|
+
"INSERT INTO metadata (id, schema_version) VALUES (1, ?)",
|
|
155
|
+
(SCHEMA_VERSION,),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def initialize_schema(cursor) -> None:
|
|
160
|
+
_ensure_metadata(cursor)
|
|
161
|
+
_ensure_downloads_table(cursor)
|
|
162
|
+
_ensure_indexes(cursor)
|
|
163
|
+
_ensure_navmesh_tables(cursor)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def reset_all_versions(cursor) -> None:
|
|
167
|
+
cursor.execute("UPDATE downloads SET version_local = 0")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def reset_versions_for_resource(cursor, resource_id: str) -> None:
|
|
171
|
+
key = f"/{resource_id}/"
|
|
172
|
+
cursor.execute(
|
|
173
|
+
"""
|
|
174
|
+
UPDATE downloads
|
|
175
|
+
SET version_local = 0
|
|
176
|
+
WHERE target_key = ?
|
|
177
|
+
OR target_key LIKE ?
|
|
178
|
+
""",
|
|
179
|
+
(
|
|
180
|
+
key,
|
|
181
|
+
f"{key}%",
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decode_protected_files(raw: str | None) -> list[str]:
|
|
187
|
+
if not raw:
|
|
188
|
+
return []
|
|
189
|
+
try:
|
|
190
|
+
value = json.loads(raw)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
return []
|
|
193
|
+
if not isinstance(value, list):
|
|
194
|
+
return []
|
|
195
|
+
return [str(item) for item in value]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _row_to_local_state(row: sqlite3.Row | tuple) -> LocalInstallState:
|
|
199
|
+
(
|
|
200
|
+
target_key,
|
|
201
|
+
resource_id,
|
|
202
|
+
parent_id,
|
|
203
|
+
parent_target_key,
|
|
204
|
+
root_resource_id,
|
|
205
|
+
target_kind,
|
|
206
|
+
category_id,
|
|
207
|
+
title,
|
|
208
|
+
version_remote,
|
|
209
|
+
version_local,
|
|
210
|
+
resolved_path,
|
|
211
|
+
subfolder,
|
|
212
|
+
flatten,
|
|
213
|
+
protected_files,
|
|
214
|
+
_remote_status,
|
|
215
|
+
is_special,
|
|
216
|
+
is_watching,
|
|
217
|
+
is_licensed,
|
|
218
|
+
is_explicit,
|
|
219
|
+
is_dependency,
|
|
220
|
+
) = row
|
|
221
|
+
|
|
222
|
+
return LocalInstallState(
|
|
223
|
+
target_key=str(target_key),
|
|
224
|
+
resource_id=str(resource_id),
|
|
225
|
+
parent_id=str(parent_id) if parent_id not in (None, 0) else None,
|
|
226
|
+
parent_target_key=parent_target_key,
|
|
227
|
+
root_resource_id=str(root_resource_id),
|
|
228
|
+
target_kind=str(target_kind),
|
|
229
|
+
category_id=category_id,
|
|
230
|
+
title=title,
|
|
231
|
+
version_local=version_local,
|
|
232
|
+
version_remote=version_remote,
|
|
233
|
+
resolved_path=resolved_path,
|
|
234
|
+
subfolder=subfolder,
|
|
235
|
+
flatten=bool(flatten),
|
|
236
|
+
protected_files=_decode_protected_files(protected_files),
|
|
237
|
+
is_special=bool(is_special),
|
|
238
|
+
is_watching=bool(is_watching),
|
|
239
|
+
is_licensed=bool(is_licensed),
|
|
240
|
+
is_explicit=bool(is_explicit),
|
|
241
|
+
is_dependency=bool(is_dependency),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def load_local_snapshot(db_path: str) -> LocalSnapshot:
|
|
246
|
+
"""Load all tracked install targets from the DB so the planner knows what's installed."""
|
|
247
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
248
|
+
async with conn.execute(
|
|
249
|
+
"""
|
|
250
|
+
SELECT
|
|
251
|
+
target_key, resource_id, parent_id, parent_target_key, root_resource_id,
|
|
252
|
+
target_kind, category_id, title, version_remote, version_local,
|
|
253
|
+
resolved_path, subfolder, flatten,
|
|
254
|
+
protected_files, remote_status,
|
|
255
|
+
is_special, is_watching, is_licensed, is_explicit, is_dependency
|
|
256
|
+
FROM downloads
|
|
257
|
+
"""
|
|
258
|
+
) as cursor:
|
|
259
|
+
rows = await cursor.fetchall()
|
|
260
|
+
|
|
261
|
+
return LocalSnapshot(
|
|
262
|
+
install_targets={
|
|
263
|
+
str(row[0]): _row_to_local_state(row)
|
|
264
|
+
for row in rows
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _desired_flags(target: DesiredInstallTarget) -> dict[str, int]:
|
|
270
|
+
"""Convert a target's source set into integer flags for the DB."""
|
|
271
|
+
return {
|
|
272
|
+
"is_special": int("special" in target.sources),
|
|
273
|
+
"is_watching": int("watching" in target.sources),
|
|
274
|
+
"is_licensed": int("licensed" in target.sources),
|
|
275
|
+
"is_explicit": int(target.explicit_root or "explicit" in target.sources),
|
|
276
|
+
"is_dependency": int(target.target_kind == "dependency"),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def _upsert_download_row(
|
|
281
|
+
conn: aiosqlite.Connection,
|
|
282
|
+
*,
|
|
283
|
+
target: DesiredInstallTarget,
|
|
284
|
+
action: PlannedAction | None,
|
|
285
|
+
remote_state: RemoteResourceState | None,
|
|
286
|
+
version_local: int | None,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Save or overwrite a single download row with the best-known values from each source."""
|
|
289
|
+
flags = _desired_flags(target)
|
|
290
|
+
persisted_category_id = (
|
|
291
|
+
action.category_id
|
|
292
|
+
if action and action.category_id is not None
|
|
293
|
+
else remote_state.category_id if remote_state and remote_state.category_id is not None
|
|
294
|
+
else target.category_id
|
|
295
|
+
)
|
|
296
|
+
persisted_title = (
|
|
297
|
+
action.title
|
|
298
|
+
if action and action.title is not None
|
|
299
|
+
else remote_state.title if remote_state and remote_state.title is not None
|
|
300
|
+
else target.title
|
|
301
|
+
)
|
|
302
|
+
persisted_resolved_path = (
|
|
303
|
+
action.resolved_path
|
|
304
|
+
if action and action.resolved_path is not None
|
|
305
|
+
else target.resolved_path
|
|
306
|
+
)
|
|
307
|
+
persisted_subfolder = (
|
|
308
|
+
action.subfolder
|
|
309
|
+
if action and action.subfolder is not None
|
|
310
|
+
else target.subfolder
|
|
311
|
+
)
|
|
312
|
+
persisted_flatten = action.flatten if action is not None else target.flatten
|
|
313
|
+
persisted_protected_files = action.protected_files if action is not None else target.protected_files
|
|
314
|
+
await conn.execute(
|
|
315
|
+
"""
|
|
316
|
+
INSERT INTO downloads (
|
|
317
|
+
target_key, resource_id, parent_id, parent_target_key, root_resource_id,
|
|
318
|
+
target_kind, category_id, title, version_remote, version_local,
|
|
319
|
+
resolved_path, subfolder, flatten,
|
|
320
|
+
protected_files, remote_status,
|
|
321
|
+
is_special, is_watching, is_licensed, is_explicit, is_dependency
|
|
322
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
323
|
+
ON CONFLICT(target_key) DO UPDATE SET
|
|
324
|
+
resource_id = excluded.resource_id,
|
|
325
|
+
parent_id = excluded.parent_id,
|
|
326
|
+
parent_target_key = excluded.parent_target_key,
|
|
327
|
+
root_resource_id = excluded.root_resource_id,
|
|
328
|
+
target_kind = excluded.target_kind,
|
|
329
|
+
category_id = excluded.category_id,
|
|
330
|
+
title = excluded.title,
|
|
331
|
+
version_remote = excluded.version_remote,
|
|
332
|
+
version_local = excluded.version_local,
|
|
333
|
+
resolved_path = excluded.resolved_path,
|
|
334
|
+
subfolder = excluded.subfolder,
|
|
335
|
+
flatten = excluded.flatten,
|
|
336
|
+
protected_files = excluded.protected_files,
|
|
337
|
+
remote_status = excluded.remote_status,
|
|
338
|
+
is_special = excluded.is_special,
|
|
339
|
+
is_watching = excluded.is_watching,
|
|
340
|
+
is_licensed = excluded.is_licensed,
|
|
341
|
+
is_explicit = excluded.is_explicit,
|
|
342
|
+
is_dependency = excluded.is_dependency
|
|
343
|
+
""",
|
|
344
|
+
(
|
|
345
|
+
target.target_key,
|
|
346
|
+
int(target.resource_id),
|
|
347
|
+
int(target.parent_id) if target.parent_id is not None else 0,
|
|
348
|
+
target.parent_target_key,
|
|
349
|
+
int(target.root_resource_id),
|
|
350
|
+
target.target_kind,
|
|
351
|
+
persisted_category_id,
|
|
352
|
+
persisted_title,
|
|
353
|
+
remote_state.version_id if remote_state else None,
|
|
354
|
+
version_local,
|
|
355
|
+
persisted_resolved_path,
|
|
356
|
+
persisted_subfolder,
|
|
357
|
+
int(persisted_flatten),
|
|
358
|
+
json.dumps(persisted_protected_files),
|
|
359
|
+
remote_state.status if remote_state else None,
|
|
360
|
+
flags["is_special"],
|
|
361
|
+
flags["is_watching"],
|
|
362
|
+
flags["is_licensed"],
|
|
363
|
+
flags["is_explicit"],
|
|
364
|
+
flags["is_dependency"],
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def record_download_success(
|
|
370
|
+
db_path: str,
|
|
371
|
+
*,
|
|
372
|
+
target: DesiredInstallTarget,
|
|
373
|
+
action: PlannedAction,
|
|
374
|
+
remote_state: RemoteResourceState,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Persist one target immediately after download so interrupted syncs keep progress."""
|
|
377
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
378
|
+
await _upsert_download_row(
|
|
379
|
+
conn,
|
|
380
|
+
target=target,
|
|
381
|
+
action=action,
|
|
382
|
+
remote_state=remote_state,
|
|
383
|
+
version_local=remote_state.version_id,
|
|
384
|
+
)
|
|
385
|
+
await conn.commit()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def record_installed_state(
|
|
389
|
+
db_path: str,
|
|
390
|
+
*,
|
|
391
|
+
desired_set: DesiredSet,
|
|
392
|
+
remote_snapshot: RemoteSnapshot,
|
|
393
|
+
local_snapshot: LocalSnapshot,
|
|
394
|
+
execution_plan: ExecutionPlan,
|
|
395
|
+
execution_result: ExecutionResult,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""End-of-run batch write for all outcomes: skips, blocks, and untracks."""
|
|
398
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
399
|
+
for target_key, action in execution_plan.actions.items():
|
|
400
|
+
result_item = execution_result.items[target_key]
|
|
401
|
+
existing = local_snapshot.install_targets.get(target_key)
|
|
402
|
+
|
|
403
|
+
if action.action == "untrack":
|
|
404
|
+
await conn.execute("DELETE FROM downloads WHERE target_key = ?", (target_key,))
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
if result_item.outcome == "downloaded":
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
desired_target = desired_set.install_targets.get(target_key)
|
|
411
|
+
if desired_target is None:
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
remote_state = remote_snapshot.resources.get(action.resource_id)
|
|
415
|
+
existing_local_version = existing.version_local if existing else None
|
|
416
|
+
version_local = existing_local_version
|
|
417
|
+
|
|
418
|
+
if result_item.outcome == "skipped" and remote_state and remote_state.version_id is not None:
|
|
419
|
+
version_local = existing_local_version if existing_local_version is not None else remote_state.version_id
|
|
420
|
+
|
|
421
|
+
await _upsert_download_row(
|
|
422
|
+
conn,
|
|
423
|
+
target=desired_target,
|
|
424
|
+
action=action,
|
|
425
|
+
remote_state=remote_state,
|
|
426
|
+
version_local=version_local,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
await conn.commit()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def reset_download_dates(cursor) -> None:
|
|
433
|
+
reset_all_versions(cursor)
|
|
434
|
+
cursor.execute("DELETE FROM navmesh_files")
|
|
435
|
+
cursor.execute("DELETE FROM navmesh_cache")
|
|
436
|
+
try:
|
|
437
|
+
meta.clear_pypi_cache()
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def reset_download_dates_for_resources(db_name: str, resource_ids: Iterable[str]) -> bool:
|
|
443
|
+
"""Force re-download of selected resources without touching anything else."""
|
|
444
|
+
try:
|
|
445
|
+
with get_db_connection(db_name) as conn:
|
|
446
|
+
cursor = conn.cursor()
|
|
447
|
+
for resource_id in resource_ids:
|
|
448
|
+
reset_versions_for_resource(cursor, resource_id)
|
|
449
|
+
conn.commit()
|
|
450
|
+
return True
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
print(f"Error resetting download dates: {exc}")
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def reset_download_dates_async(db_path: str) -> None:
|
|
457
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
458
|
+
await conn.execute("UPDATE downloads SET version_local = 0")
|
|
459
|
+
await conn.execute("DELETE FROM navmesh_files")
|
|
460
|
+
await conn.execute("DELETE FROM navmesh_cache")
|
|
461
|
+
await conn.commit()
|
|
462
|
+
try:
|
|
463
|
+
meta.clear_pypi_cache()
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def list_resources(cursor) -> list[tuple[int, str]]:
|
|
469
|
+
cursor.execute(
|
|
470
|
+
"""
|
|
471
|
+
SELECT resource_id, title
|
|
472
|
+
FROM downloads
|
|
473
|
+
WHERE parent_target_key IS NULL
|
|
474
|
+
ORDER BY resource_id
|
|
475
|
+
"""
|
|
476
|
+
)
|
|
477
|
+
return cursor.fetchall()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def list_dependencies(cursor) -> list[tuple[int, str]]:
|
|
481
|
+
cursor.execute(
|
|
482
|
+
"""
|
|
483
|
+
SELECT resource_id, title
|
|
484
|
+
FROM downloads
|
|
485
|
+
WHERE parent_target_key IS NOT NULL
|
|
486
|
+
ORDER BY root_resource_id, target_key
|
|
487
|
+
"""
|
|
488
|
+
)
|
|
489
|
+
return cursor.fetchall()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def fetch_root_version_local(db_path: str, resource_id: str) -> int | None:
|
|
493
|
+
"""Return the local version stamp for a resource, or None if not installed."""
|
|
494
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
495
|
+
async with conn.execute(
|
|
496
|
+
"""
|
|
497
|
+
SELECT version_local
|
|
498
|
+
FROM downloads
|
|
499
|
+
WHERE resource_id = ? AND parent_target_key IS NULL
|
|
500
|
+
LIMIT 1
|
|
501
|
+
""",
|
|
502
|
+
(int(resource_id),),
|
|
503
|
+
) as cursor:
|
|
504
|
+
row = await cursor.fetchone()
|
|
505
|
+
return row[0] if row else None
|