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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Concurrent download executor for the planned sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from redfetch import download
|
|
11
|
+
from redfetch.sync_types import (
|
|
12
|
+
DesiredInstallTarget,
|
|
13
|
+
DesiredSet,
|
|
14
|
+
ExecutionPlan,
|
|
15
|
+
ExecutionResult,
|
|
16
|
+
ExecutionResultItem,
|
|
17
|
+
PlannedAction,
|
|
18
|
+
PlanReason,
|
|
19
|
+
RemoteResourceState,
|
|
20
|
+
RemoteSnapshot,
|
|
21
|
+
ResultOutcome,
|
|
22
|
+
SyncEvent,
|
|
23
|
+
SyncEventCallback,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DOWNLOAD_CONCURRENCY = 6
|
|
27
|
+
|
|
28
|
+
_INSTANT_OUTCOMES = {"skip": "skipped", "block": "blocked", "untrack": "untracked"}
|
|
29
|
+
|
|
30
|
+
DownloadSuccessHook = Callable[
|
|
31
|
+
[DesiredInstallTarget, PlannedAction, RemoteResourceState], Awaitable[None]
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_item(
|
|
36
|
+
action: PlannedAction,
|
|
37
|
+
outcome: ResultOutcome,
|
|
38
|
+
*,
|
|
39
|
+
reason: PlanReason | None = None,
|
|
40
|
+
version: int | None = None,
|
|
41
|
+
error: str | None = None,
|
|
42
|
+
) -> ExecutionResultItem:
|
|
43
|
+
"""Fill out a result record for the resource. Auto-copies identity fields so callers only pass what varies."""
|
|
44
|
+
return ExecutionResultItem(
|
|
45
|
+
target_key=action.target_key,
|
|
46
|
+
resource_id=action.resource_id,
|
|
47
|
+
outcome=outcome,
|
|
48
|
+
reason=reason or action.reason,
|
|
49
|
+
written_version=version,
|
|
50
|
+
error_detail=error,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _do_download(
|
|
55
|
+
client: httpx.AsyncClient, action: PlannedAction,
|
|
56
|
+
) -> tuple[bool, str | None]:
|
|
57
|
+
"""Perform one download attempt, returning success and any error."""
|
|
58
|
+
try:
|
|
59
|
+
ok = await download.download_install_target_async(
|
|
60
|
+
client=client,
|
|
61
|
+
resource_id=action.resource_id,
|
|
62
|
+
download_url=action.artifact.download_url,
|
|
63
|
+
filename=action.artifact.filename,
|
|
64
|
+
file_hash=action.artifact.file_hash,
|
|
65
|
+
folder_path=action.resolved_path,
|
|
66
|
+
should_flatten=action.flatten,
|
|
67
|
+
protected_files=action.protected_files,
|
|
68
|
+
)
|
|
69
|
+
except asyncio.CancelledError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
return False, str(exc)
|
|
73
|
+
return ok, (None if ok else "download failed")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def execute_plan(
|
|
77
|
+
*,
|
|
78
|
+
headers: dict,
|
|
79
|
+
desired_set: DesiredSet,
|
|
80
|
+
remote_snapshot: RemoteSnapshot,
|
|
81
|
+
execution_plan: ExecutionPlan,
|
|
82
|
+
on_event: SyncEventCallback | None = None,
|
|
83
|
+
on_download_success: DownloadSuccessHook | None = None,
|
|
84
|
+
) -> ExecutionResult:
|
|
85
|
+
"""Execute planned actions (skips, downloads, etc.) and build the result report."""
|
|
86
|
+
result = ExecutionResult(items={})
|
|
87
|
+
satisfied: set[str] = set()
|
|
88
|
+
|
|
89
|
+
def emit(event: SyncEvent) -> None:
|
|
90
|
+
if on_event:
|
|
91
|
+
on_event(event)
|
|
92
|
+
|
|
93
|
+
for action in execution_plan.actions.values():
|
|
94
|
+
outcome = _INSTANT_OUTCOMES.get(action.action)
|
|
95
|
+
if outcome is None:
|
|
96
|
+
continue
|
|
97
|
+
version = action.remote_version if action.action == "skip" else None
|
|
98
|
+
result.items[action.target_key] = _make_item(action, outcome, version=version)
|
|
99
|
+
if action.action == "skip":
|
|
100
|
+
satisfied.add(action.target_key)
|
|
101
|
+
emit(("done", action.resource_id, outcome))
|
|
102
|
+
|
|
103
|
+
download_actions = {
|
|
104
|
+
k: a for k, a in execution_plan.actions.items() if a.action == "download"
|
|
105
|
+
}
|
|
106
|
+
if not download_actions:
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
sem = asyncio.Semaphore(DOWNLOAD_CONCURRENCY)
|
|
110
|
+
gate: dict[str, asyncio.Event] = {k: asyncio.Event() for k in download_actions}
|
|
111
|
+
|
|
112
|
+
async def run_one(key: str, client: httpx.AsyncClient) -> None:
|
|
113
|
+
action = download_actions[key]
|
|
114
|
+
parent = action.parent_target_key
|
|
115
|
+
try:
|
|
116
|
+
if parent and parent in gate:
|
|
117
|
+
await gate[parent].wait()
|
|
118
|
+
|
|
119
|
+
if parent and parent not in satisfied:
|
|
120
|
+
result.items[key] = _make_item(action, "blocked", reason="parent_failed")
|
|
121
|
+
emit(("done", action.resource_id, "blocked"))
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if action.artifact is None or action.resolved_path is None:
|
|
125
|
+
result.items[key] = _make_item(
|
|
126
|
+
action, "error", error="missing artifact or resolved path",
|
|
127
|
+
)
|
|
128
|
+
emit(("done", action.resource_id, "error"))
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
async with sem:
|
|
132
|
+
emit(("start", action.resource_id, action.title))
|
|
133
|
+
ok, error_detail = await _do_download(client, action)
|
|
134
|
+
|
|
135
|
+
if ok:
|
|
136
|
+
result.items[key] = _make_item(
|
|
137
|
+
action, "downloaded", version=action.remote_version,
|
|
138
|
+
)
|
|
139
|
+
satisfied.add(key)
|
|
140
|
+
if on_download_success:
|
|
141
|
+
await on_download_success(
|
|
142
|
+
desired_set.install_targets[key],
|
|
143
|
+
action,
|
|
144
|
+
remote_snapshot.resources[action.resource_id],
|
|
145
|
+
)
|
|
146
|
+
emit(("done", action.resource_id, "downloaded"))
|
|
147
|
+
else:
|
|
148
|
+
result.items[key] = _make_item(action, "error", error=error_detail)
|
|
149
|
+
emit(("done", action.resource_id, "error"))
|
|
150
|
+
finally:
|
|
151
|
+
gate[key].set()
|
|
152
|
+
|
|
153
|
+
async with httpx.AsyncClient(
|
|
154
|
+
headers=headers,
|
|
155
|
+
http2=True,
|
|
156
|
+
timeout=60.0,
|
|
157
|
+
limits=httpx.Limits(
|
|
158
|
+
max_connections=DOWNLOAD_CONCURRENCY,
|
|
159
|
+
max_keepalive_connections=DOWNLOAD_CONCURRENCY,
|
|
160
|
+
),
|
|
161
|
+
) as client:
|
|
162
|
+
await asyncio.gather(*(run_one(k, client) for k in download_actions))
|
|
163
|
+
|
|
164
|
+
return result
|
redfetch/sync_planner.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Decides download/skip/block actions for the planned sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from redfetch import config
|
|
6
|
+
from redfetch.sync_discovery import (
|
|
7
|
+
is_special_resource,
|
|
8
|
+
resolve_dependency_path,
|
|
9
|
+
resolve_root_path,
|
|
10
|
+
)
|
|
11
|
+
from redfetch.sync_types import (
|
|
12
|
+
ActionType,
|
|
13
|
+
DesiredInstallTarget,
|
|
14
|
+
DesiredSet,
|
|
15
|
+
ExecutionPlan,
|
|
16
|
+
LocalInstallState,
|
|
17
|
+
LocalSnapshot,
|
|
18
|
+
PlannedAction,
|
|
19
|
+
PlanReason,
|
|
20
|
+
RemoteResourceState,
|
|
21
|
+
RemoteSnapshot,
|
|
22
|
+
parse_target_key,
|
|
23
|
+
target_depth,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
BLOCKING_STATUSES = {"access_denied", "no_files", "multiple_files", "not_found", "fetch_error"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _desired_targets_in_order(desired_set: DesiredSet) -> list[DesiredInstallTarget]:
|
|
31
|
+
"""Sort targets so parents are always planned before their children."""
|
|
32
|
+
return sorted(
|
|
33
|
+
desired_set.install_targets.values(),
|
|
34
|
+
key=lambda target: (target_depth(target.target_key), target.target_key),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cycle_block_keys(desired_set: DesiredSet) -> set[str]:
|
|
39
|
+
"""Block targets that would cause circular dependency loops."""
|
|
40
|
+
cycle_keys: set[str] = set()
|
|
41
|
+
for target in desired_set.install_targets.values():
|
|
42
|
+
segments = parse_target_key(target.target_key)
|
|
43
|
+
if len(segments) == len(set(segments)):
|
|
44
|
+
continue
|
|
45
|
+
repeated_resource_id = segments[-1]
|
|
46
|
+
first_index = segments[:-1].index(repeated_resource_id)
|
|
47
|
+
first_blocked_prefix = first_index + 2
|
|
48
|
+
for prefix_length in range(first_blocked_prefix, len(segments) + 1):
|
|
49
|
+
candidate_key = f"/{'/'.join(segments[:prefix_length])}/"
|
|
50
|
+
if candidate_key in desired_set.install_targets:
|
|
51
|
+
cycle_keys.add(candidate_key)
|
|
52
|
+
return cycle_keys
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_target_path(
|
|
56
|
+
target: DesiredInstallTarget,
|
|
57
|
+
*,
|
|
58
|
+
parent_action: PlannedAction | None,
|
|
59
|
+
remote_state: RemoteResourceState | None,
|
|
60
|
+
settings_env: str,
|
|
61
|
+
) -> tuple[str | None, str | None]:
|
|
62
|
+
"""Figure out where on disk this target should be installed."""
|
|
63
|
+
if target.resolved_path is not None:
|
|
64
|
+
return target.resolved_path, target.subfolder
|
|
65
|
+
|
|
66
|
+
if target.target_kind == "root":
|
|
67
|
+
category_id = target.category_id
|
|
68
|
+
if category_id is None and remote_state is not None:
|
|
69
|
+
category_id = remote_state.category_id
|
|
70
|
+
if not is_special_resource(target.resource_id, settings_env):
|
|
71
|
+
if category_id is None or category_id not in config.CATEGORY_MAP:
|
|
72
|
+
return None, target.subfolder
|
|
73
|
+
return resolve_root_path(target.resource_id, category_id, settings_env), None
|
|
74
|
+
|
|
75
|
+
if parent_action is None or not parent_action.resolved_path or target.parent_id is None:
|
|
76
|
+
return None, target.subfolder
|
|
77
|
+
resolved_path, subfolder = resolve_dependency_path(
|
|
78
|
+
parent_action.resolved_path,
|
|
79
|
+
target.parent_id,
|
|
80
|
+
target.resource_id,
|
|
81
|
+
settings_env,
|
|
82
|
+
)
|
|
83
|
+
return resolved_path, (target.subfolder or subfolder)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _install_context_changed(
|
|
87
|
+
local_state: LocalInstallState | None,
|
|
88
|
+
*,
|
|
89
|
+
resolved_path: str | None,
|
|
90
|
+
subfolder: str | None,
|
|
91
|
+
target: DesiredInstallTarget,
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Check if path, subfolder, or install settings have changed since the last sync."""
|
|
94
|
+
if local_state is None:
|
|
95
|
+
return False
|
|
96
|
+
return (
|
|
97
|
+
local_state.resolved_path != resolved_path
|
|
98
|
+
or local_state.subfolder != subfolder
|
|
99
|
+
or local_state.flatten != target.flatten
|
|
100
|
+
or local_state.protected_files != target.protected_files
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decide_action(
|
|
105
|
+
target: DesiredInstallTarget,
|
|
106
|
+
*,
|
|
107
|
+
local_state: LocalInstallState | None,
|
|
108
|
+
remote_state: RemoteResourceState | None,
|
|
109
|
+
parent_action: PlannedAction | None,
|
|
110
|
+
cycle_block_keys: set[str],
|
|
111
|
+
resolved_path: str | None,
|
|
112
|
+
subfolder: str | None,
|
|
113
|
+
) -> tuple[ActionType, PlanReason]:
|
|
114
|
+
"""Decide whether a single target should be downloaded, skipped, or blocked, and why."""
|
|
115
|
+
if target.target_key in cycle_block_keys:
|
|
116
|
+
return "block", "dependency_cycle"
|
|
117
|
+
if target.parent_target_key and (
|
|
118
|
+
parent_action is None or parent_action.action in {"block", "untrack"}
|
|
119
|
+
):
|
|
120
|
+
return "block", "parent_blocked"
|
|
121
|
+
if target.discovery_block:
|
|
122
|
+
return "block", target.discovery_block
|
|
123
|
+
if remote_state is None:
|
|
124
|
+
return "block", "fetch_error"
|
|
125
|
+
if remote_state.status in BLOCKING_STATUSES:
|
|
126
|
+
return "block", remote_state.status # type: ignore[return-value]
|
|
127
|
+
if resolved_path is None and target.target_kind == "root":
|
|
128
|
+
return "block", "unknown_category"
|
|
129
|
+
if _install_context_changed(
|
|
130
|
+
local_state, resolved_path=resolved_path, subfolder=subfolder, target=target,
|
|
131
|
+
):
|
|
132
|
+
return "download", "install_context_changed"
|
|
133
|
+
if (
|
|
134
|
+
local_state is not None
|
|
135
|
+
and remote_state.version_id is not None
|
|
136
|
+
and local_state.version_local == remote_state.version_id
|
|
137
|
+
):
|
|
138
|
+
return "skip", "already_current"
|
|
139
|
+
if local_state is None or local_state.version_local is None:
|
|
140
|
+
return "download", "not_installed"
|
|
141
|
+
return "download", "outdated"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_execution_plan(
|
|
145
|
+
*,
|
|
146
|
+
desired_set: DesiredSet,
|
|
147
|
+
remote_snapshot: RemoteSnapshot,
|
|
148
|
+
local_snapshot: LocalSnapshot,
|
|
149
|
+
settings_env: str,
|
|
150
|
+
) -> ExecutionPlan:
|
|
151
|
+
"""Main planner entry point: decide the action for every target, then mark untracked leftovers."""
|
|
152
|
+
actions: dict[str, PlannedAction] = {}
|
|
153
|
+
cycle_keys = _cycle_block_keys(desired_set)
|
|
154
|
+
|
|
155
|
+
for target in _desired_targets_in_order(desired_set):
|
|
156
|
+
local_state = local_snapshot.install_targets.get(target.target_key)
|
|
157
|
+
remote_state = remote_snapshot.resources.get(target.resource_id)
|
|
158
|
+
parent_action = actions.get(target.parent_target_key) if target.parent_target_key else None
|
|
159
|
+
resolved_path, subfolder = _resolve_target_path(
|
|
160
|
+
target,
|
|
161
|
+
parent_action=parent_action,
|
|
162
|
+
remote_state=remote_state,
|
|
163
|
+
settings_env=settings_env,
|
|
164
|
+
)
|
|
165
|
+
action, reason = _decide_action(
|
|
166
|
+
target,
|
|
167
|
+
local_state=local_state,
|
|
168
|
+
remote_state=remote_state,
|
|
169
|
+
parent_action=parent_action,
|
|
170
|
+
cycle_block_keys=cycle_keys,
|
|
171
|
+
resolved_path=resolved_path,
|
|
172
|
+
subfolder=subfolder,
|
|
173
|
+
)
|
|
174
|
+
actions[target.target_key] = PlannedAction.from_desired(
|
|
175
|
+
target,
|
|
176
|
+
action=action,
|
|
177
|
+
reason=reason,
|
|
178
|
+
title=remote_state.title if remote_state else target.title,
|
|
179
|
+
category_id=remote_state.category_id if remote_state else target.category_id,
|
|
180
|
+
remote_version=remote_state.version_id if remote_state else None,
|
|
181
|
+
artifact=remote_state.artifact if remote_state and action == "download" else None,
|
|
182
|
+
resolved_path=resolved_path,
|
|
183
|
+
subfolder=subfolder,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if desired_set.mode == "full":
|
|
187
|
+
local_scope = local_snapshot.install_targets.values()
|
|
188
|
+
else:
|
|
189
|
+
local_scope = local_snapshot.roots_in_closure(desired_set.requested_root_ids)
|
|
190
|
+
|
|
191
|
+
for local_state in local_scope:
|
|
192
|
+
if local_state.target_key in actions:
|
|
193
|
+
continue
|
|
194
|
+
actions[local_state.target_key] = PlannedAction.untrack_from_local(local_state)
|
|
195
|
+
|
|
196
|
+
return ExecutionPlan(actions=actions)
|
redfetch/sync_remote.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Fetches info about resources from the website."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from redfetch import api
|
|
10
|
+
from redfetch import net
|
|
11
|
+
from redfetch.sync_discovery import payload_category_id, payload_title, payload_version_id
|
|
12
|
+
from redfetch.sync_types import (
|
|
13
|
+
DesiredInstallTarget,
|
|
14
|
+
DesiredSet,
|
|
15
|
+
LocalSnapshot,
|
|
16
|
+
RemoteArtifact,
|
|
17
|
+
RemoteResourceState,
|
|
18
|
+
RemoteSnapshot,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_hash(raw_hash: str | None) -> str | None:
|
|
23
|
+
"""Sanitize the xenforo file hash to a standard format."""
|
|
24
|
+
if not raw_hash:
|
|
25
|
+
return None
|
|
26
|
+
cleaned = str(raw_hash).strip().lower()
|
|
27
|
+
if len(cleaned) == 32 and all(char in "0123456789abcdef" for char in cleaned):
|
|
28
|
+
return cleaned
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _PayloadDetails(NamedTuple):
|
|
33
|
+
"""Bundle of fields extracted from the API for building a RemoteResourceState."""
|
|
34
|
+
title: str | None
|
|
35
|
+
category_id: int | None
|
|
36
|
+
version_id: int | None
|
|
37
|
+
artifact: RemoteArtifact | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_artifact(payload: dict) -> RemoteArtifact | None:
|
|
41
|
+
"""Extract downloadable file info."""
|
|
42
|
+
current_files = payload.get("current_files")
|
|
43
|
+
if not isinstance(current_files, list) or len(current_files) != 1:
|
|
44
|
+
return None
|
|
45
|
+
file_info = current_files[0]
|
|
46
|
+
if file_info.get("id") is None or not file_info.get("filename") or not file_info.get("download_url"):
|
|
47
|
+
return None
|
|
48
|
+
return RemoteArtifact(
|
|
49
|
+
file_id=int(file_info["id"]),
|
|
50
|
+
filename=str(file_info["filename"]),
|
|
51
|
+
download_url=str(file_info["download_url"]),
|
|
52
|
+
file_hash=_normalize_hash(file_info.get("hash")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _payload_details(payload: dict | None) -> _PayloadDetails:
|
|
57
|
+
"""Bundle all fields extracted from a raw API payload."""
|
|
58
|
+
if not payload:
|
|
59
|
+
return _PayloadDetails(None, None, None, None)
|
|
60
|
+
return _PayloadDetails(
|
|
61
|
+
title=payload_title(payload),
|
|
62
|
+
category_id=payload_category_id(payload),
|
|
63
|
+
version_id=payload_version_id(payload),
|
|
64
|
+
artifact=_extract_artifact(payload),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _needs_live_check(
|
|
69
|
+
*,
|
|
70
|
+
desired_targets: list[DesiredInstallTarget],
|
|
71
|
+
local_snapshot: LocalSnapshot,
|
|
72
|
+
manifest_details: _PayloadDetails | None,
|
|
73
|
+
) -> bool:
|
|
74
|
+
"""Return True if the resource needs an update, or data is incomplete, or config has changed."""
|
|
75
|
+
if manifest_details is None or manifest_details.version_id is None or manifest_details.artifact is None:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
for target in desired_targets:
|
|
79
|
+
local_state = local_snapshot.install_targets.get(target.target_key)
|
|
80
|
+
if local_state is None:
|
|
81
|
+
return True
|
|
82
|
+
if local_state.version_local is None:
|
|
83
|
+
return True
|
|
84
|
+
if local_state.version_local != manifest_details.version_id:
|
|
85
|
+
return True
|
|
86
|
+
if (
|
|
87
|
+
local_state.resolved_path != target.resolved_path
|
|
88
|
+
or local_state.subfolder != target.subfolder
|
|
89
|
+
or local_state.flatten != target.flatten
|
|
90
|
+
or local_state.protected_files != target.protected_files
|
|
91
|
+
):
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _blocked_state(
|
|
98
|
+
resource_id: str,
|
|
99
|
+
*,
|
|
100
|
+
status: str,
|
|
101
|
+
manifest_details: _PayloadDetails | None,
|
|
102
|
+
live_title: str | None = None,
|
|
103
|
+
live_category_id: int | None = None,
|
|
104
|
+
) -> RemoteResourceState:
|
|
105
|
+
"""Build a RemoteResourceState for a resource that is blocked."""
|
|
106
|
+
return RemoteResourceState(
|
|
107
|
+
resource_id=resource_id,
|
|
108
|
+
title=(manifest_details.title if manifest_details else None) or live_title,
|
|
109
|
+
category_id=(manifest_details.category_id if manifest_details and manifest_details.category_id is not None else live_category_id),
|
|
110
|
+
version_id=manifest_details.version_id if manifest_details else None,
|
|
111
|
+
status=status,
|
|
112
|
+
artifact=None,
|
|
113
|
+
source_note="manifest" if manifest_details else "live_access_only",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def fetch_remote_snapshot(
|
|
118
|
+
*,
|
|
119
|
+
client: httpx.AsyncClient,
|
|
120
|
+
desired_set: DesiredSet,
|
|
121
|
+
local_snapshot: LocalSnapshot,
|
|
122
|
+
) -> RemoteSnapshot:
|
|
123
|
+
"""Assemble the remote half of the sync picture: version, status, and download info per resource."""
|
|
124
|
+
manifest = await net.fetch_manifest_cached(client)
|
|
125
|
+
manifest_resources = manifest.get("resources", {}) or {}
|
|
126
|
+
|
|
127
|
+
remote_resources: dict[str, RemoteResourceState] = {}
|
|
128
|
+
ids_needing_live_check: list[str] = []
|
|
129
|
+
manifest_cache: dict[str, _PayloadDetails | None] = {}
|
|
130
|
+
|
|
131
|
+
for resource_id in desired_set.resource_ids:
|
|
132
|
+
manifest_entry = manifest_resources.get(resource_id)
|
|
133
|
+
manifest_details = _payload_details(manifest_entry) if manifest_entry else None
|
|
134
|
+
manifest_cache[resource_id] = manifest_details
|
|
135
|
+
desired_targets = desired_set.resource_targets(resource_id)
|
|
136
|
+
if _needs_live_check(
|
|
137
|
+
desired_targets=desired_targets,
|
|
138
|
+
local_snapshot=local_snapshot,
|
|
139
|
+
manifest_details=manifest_details,
|
|
140
|
+
):
|
|
141
|
+
ids_needing_live_check.append(resource_id)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
remote_resources[resource_id] = RemoteResourceState(
|
|
145
|
+
resource_id=resource_id,
|
|
146
|
+
title=manifest_details.title,
|
|
147
|
+
category_id=manifest_details.category_id,
|
|
148
|
+
version_id=manifest_details.version_id,
|
|
149
|
+
status="manifest_current",
|
|
150
|
+
artifact=manifest_details.artifact,
|
|
151
|
+
source_note="manifest_only",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if ids_needing_live_check:
|
|
155
|
+
live_records = await api.fetch_resource_records_batch(client, ids_needing_live_check)
|
|
156
|
+
for record in live_records:
|
|
157
|
+
resource_id = record.resource_id
|
|
158
|
+
manifest_details = manifest_cache.get(resource_id)
|
|
159
|
+
live_title = payload_title(record.resource)
|
|
160
|
+
live_category_id = payload_category_id(record.resource)
|
|
161
|
+
|
|
162
|
+
if record.status != "downloadable":
|
|
163
|
+
remote_resources[resource_id] = _blocked_state(
|
|
164
|
+
resource_id,
|
|
165
|
+
status=record.status,
|
|
166
|
+
manifest_details=manifest_details,
|
|
167
|
+
live_title=live_title,
|
|
168
|
+
live_category_id=live_category_id,
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
remote_resources[resource_id] = RemoteResourceState(
|
|
173
|
+
resource_id=resource_id,
|
|
174
|
+
title=(manifest_details.title if manifest_details else None) or live_title,
|
|
175
|
+
category_id=(manifest_details.category_id if manifest_details and manifest_details.category_id is not None else live_category_id),
|
|
176
|
+
version_id=manifest_details.version_id if manifest_details else None,
|
|
177
|
+
status="downloadable",
|
|
178
|
+
artifact=manifest_details.artifact if manifest_details else None,
|
|
179
|
+
source_note="manifest_plus_access_check" if manifest_details else "live_access_only",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return RemoteSnapshot(resources=remote_resources)
|