eticu 0.2.1__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.
- eticu/__init__.py +1 -0
- eticu/cli.py +229 -0
- eticu/config.py +24 -0
- eticu/convert.py +130 -0
- eticu/download.py +115 -0
- eticu/fit_parse.py +286 -0
- eticu/intervals_client.py +224 -0
- eticu/models.py +28 -0
- eticu/plan.py +69 -0
- eticu/scrape.py +90 -0
- eticu/upload.py +173 -0
- eticu/zones.py +38 -0
- eticu-0.2.1.dist-info/METADATA +109 -0
- eticu-0.2.1.dist-info/RECORD +16 -0
- eticu-0.2.1.dist-info/WHEEL +4 -0
- eticu-0.2.1.dist-info/entry_points.txt +2 -0
eticu/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
eticu/cli.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""eticu CLI — scrape / download / convert / upload / all."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Convert 80/20 Endurance workouts to intervals.icu structured workouts.")
|
|
13
|
+
|
|
14
|
+
_DEFAULT_CACHE = Path("8020_cache")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _setup_logging(verbose: bool) -> None:
|
|
18
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
19
|
+
logging.basicConfig(level=level, format="%(levelname)s %(message)s", stream=sys.stderr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# scrape
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def scrape(
|
|
29
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List all .FIT URLs from the 80/20 workout library (no download)."""
|
|
32
|
+
_setup_logging(verbose)
|
|
33
|
+
from eticu.scrape import scrape_workout_urls
|
|
34
|
+
|
|
35
|
+
grouped = scrape_workout_urls()
|
|
36
|
+
for sport, urls in grouped.items():
|
|
37
|
+
for url in urls:
|
|
38
|
+
typer.echo(f"{sport}\t{url}")
|
|
39
|
+
total = sum(len(v) for v in grouped.values())
|
|
40
|
+
run, ride, swim = len(grouped["Run"]), len(grouped["Ride"]), len(grouped["Swim"])
|
|
41
|
+
typer.echo(f"\nTotal: {total} (Run={run} Ride={ride} Swim={swim})")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# download
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def download(
|
|
51
|
+
cache_dir: Annotated[Path, typer.Option("--cache-dir")] = _DEFAULT_CACHE,
|
|
52
|
+
workers: Annotated[int, typer.Option("--jobs", "-j")] = 4,
|
|
53
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
54
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Download all .FIT files to a local cache directory."""
|
|
57
|
+
_setup_logging(verbose)
|
|
58
|
+
from eticu.download import download_all
|
|
59
|
+
from eticu.scrape import scrape_workout_urls
|
|
60
|
+
|
|
61
|
+
grouped = scrape_workout_urls()
|
|
62
|
+
all_urls = [u for urls in grouped.values() for u in urls]
|
|
63
|
+
typer.echo(f"Downloading {len(all_urls)} files to {cache_dir} ...")
|
|
64
|
+
counts = download_all(all_urls, cache_dir, workers=workers, dry_run=dry_run)
|
|
65
|
+
typer.echo(f"Done. ok={counts['ok']} skip={counts['skip']} error={counts['error']}")
|
|
66
|
+
if counts["error"]:
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# convert
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command(name="convert")
|
|
76
|
+
def convert_cmd(
|
|
77
|
+
fit_file: Annotated[Path, typer.Argument(help=".FIT file to convert")],
|
|
78
|
+
cycling_power: Annotated[bool, typer.Option("--cycling-power")] = False,
|
|
79
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Convert a single .FIT workout file and print the intervals.icu text."""
|
|
82
|
+
_setup_logging(verbose)
|
|
83
|
+
from eticu.convert import convert as do_convert
|
|
84
|
+
from eticu.fit_parse import parse_fit
|
|
85
|
+
|
|
86
|
+
workout = parse_fit(fit_file)
|
|
87
|
+
typer.echo(do_convert(workout, cycling_power=cycling_power))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# upload
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def upload(
|
|
97
|
+
cache_dir: Annotated[Path, typer.Option("--cache-dir")] = _DEFAULT_CACHE,
|
|
98
|
+
cycling_power: Annotated[bool, typer.Option("--cycling-power")] = False,
|
|
99
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
100
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Upload converted workouts to intervals.icu workout library."""
|
|
103
|
+
_setup_logging(verbose)
|
|
104
|
+
from eticu.config import get_settings
|
|
105
|
+
from eticu.intervals_client import IntervalsClient
|
|
106
|
+
from eticu.upload import upload_workouts
|
|
107
|
+
|
|
108
|
+
settings = get_settings()
|
|
109
|
+
if not settings.intervals_api_key or not settings.intervals_athlete_id:
|
|
110
|
+
typer.echo("Error: INTERVALS_API_KEY and INTERVALS_ATHLETE_ID must be set.", err=True)
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
|
|
113
|
+
fit_files = sorted(set(cache_dir.rglob("*.FIT")) | set(cache_dir.rglob("*.fit")))
|
|
114
|
+
if not fit_files:
|
|
115
|
+
typer.echo(f"No .FIT files found in {cache_dir}. Run 'eticu download' first.")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
typer.echo(f"Uploading {len(fit_files)} workout(s) ...")
|
|
119
|
+
with IntervalsClient(settings.intervals_api_key, settings.intervals_athlete_id) as client:
|
|
120
|
+
counts = upload_workouts(fit_files, client, cycling_power=cycling_power, dry_run=dry_run)
|
|
121
|
+
|
|
122
|
+
typer.echo(
|
|
123
|
+
f"Done. created={counts['created']} updated={counts['updated']} "
|
|
124
|
+
f"skipped={counts['skipped']} error={counts['error']}"
|
|
125
|
+
)
|
|
126
|
+
if counts["error"]:
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# upload-plan
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command(name="upload-plan")
|
|
136
|
+
def upload_plan_cmd(
|
|
137
|
+
plan_csv: Annotated[Path, typer.Argument(help="CSV file containing the plan")],
|
|
138
|
+
name: Annotated[str, typer.Option("--name", "-n", help="Name of the training plan folder")],
|
|
139
|
+
pool_length: Annotated[int | None, typer.Option("--pool-length", help="Pool length in meters (e.g., 25 or 50)")] = None,
|
|
140
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
141
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Upload a training plan from a CSV to intervals.icu."""
|
|
144
|
+
_setup_logging(verbose)
|
|
145
|
+
from eticu.config import get_settings
|
|
146
|
+
from eticu.intervals_client import IntervalsClient
|
|
147
|
+
from eticu.upload import upload_plan
|
|
148
|
+
|
|
149
|
+
settings = get_settings()
|
|
150
|
+
if not dry_run and (not settings.intervals_api_key or not settings.intervals_athlete_id):
|
|
151
|
+
typer.echo("Error: INTERVALS_API_KEY and INTERVALS_ATHLETE_ID must be set.", err=True)
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
typer.echo(f"Uploading plan {name!r} from {plan_csv} ...")
|
|
155
|
+
if dry_run:
|
|
156
|
+
typer.echo(" [DRY-RUN] Will not perform actual upload.")
|
|
157
|
+
|
|
158
|
+
with IntervalsClient(settings.intervals_api_key, settings.intervals_athlete_id) as client:
|
|
159
|
+
counts = upload_plan(
|
|
160
|
+
csv_file=plan_csv,
|
|
161
|
+
plan_name=name,
|
|
162
|
+
client=client,
|
|
163
|
+
pool_length_m=pool_length,
|
|
164
|
+
dry_run=dry_run,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
typer.echo(
|
|
168
|
+
f"Done. created={counts['created']} updated={counts['updated']} "
|
|
169
|
+
f"skipped={counts['skipped']} error={counts['error']} missing={counts['missing']}"
|
|
170
|
+
)
|
|
171
|
+
if counts["error"] or counts["missing"]:
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# all
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command(name="all")
|
|
181
|
+
def all_cmd(
|
|
182
|
+
cache_dir: Annotated[Path, typer.Option("--cache-dir")] = _DEFAULT_CACHE,
|
|
183
|
+
workers: Annotated[int, typer.Option("--jobs", "-j")] = 4,
|
|
184
|
+
cycling_power: Annotated[bool, typer.Option("--cycling-power")] = False,
|
|
185
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
186
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Run the full pipeline: scrape → download → upload."""
|
|
189
|
+
_setup_logging(verbose)
|
|
190
|
+
from eticu.config import get_settings
|
|
191
|
+
from eticu.download import download_all
|
|
192
|
+
from eticu.intervals_client import IntervalsClient
|
|
193
|
+
from eticu.scrape import scrape_workout_urls
|
|
194
|
+
from eticu.upload import upload_workouts
|
|
195
|
+
|
|
196
|
+
settings = get_settings()
|
|
197
|
+
if not dry_run and (not settings.intervals_api_key or not settings.intervals_athlete_id):
|
|
198
|
+
typer.echo("Error: INTERVALS_API_KEY and INTERVALS_ATHLETE_ID must be set.", err=True)
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
typer.echo("Step 1/3: Scraping workout URLs ...")
|
|
202
|
+
grouped = scrape_workout_urls()
|
|
203
|
+
all_urls = [u for urls in grouped.values() for u in urls]
|
|
204
|
+
typer.echo(f" Found {len(all_urls)} URLs.")
|
|
205
|
+
|
|
206
|
+
typer.echo(f"Step 2/3: Downloading to {cache_dir} ...")
|
|
207
|
+
dl = download_all(all_urls, cache_dir, workers=workers, dry_run=dry_run)
|
|
208
|
+
typer.echo(f" ok={dl['ok']} skip={dl['skip']} error={dl['error']}")
|
|
209
|
+
|
|
210
|
+
fit_files = sorted(set(cache_dir.rglob("*.FIT")) | set(cache_dir.rglob("*.fit")))
|
|
211
|
+
typer.echo(f"Step 3/3: Uploading {len(fit_files)} workout(s) ...")
|
|
212
|
+
|
|
213
|
+
if dry_run:
|
|
214
|
+
typer.echo(" [DRY-RUN] Skipping upload.")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
with IntervalsClient(settings.intervals_api_key, settings.intervals_athlete_id) as client:
|
|
218
|
+
counts = upload_workouts(fit_files, client, cycling_power=cycling_power, dry_run=dry_run)
|
|
219
|
+
|
|
220
|
+
typer.echo(
|
|
221
|
+
f" created={counts['created']} updated={counts['updated']} "
|
|
222
|
+
f"skipped={counts['skipped']} error={counts['error']}"
|
|
223
|
+
)
|
|
224
|
+
if counts["error"]:
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
app()
|
eticu/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
8
|
+
|
|
9
|
+
intervals_api_key: str = ""
|
|
10
|
+
intervals_athlete_id: str = ""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def base_url(self) -> str:
|
|
14
|
+
return f"https://intervals.icu/api/v1/athlete/{self.intervals_athlete_id}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_settings: Settings | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_settings() -> Settings:
|
|
21
|
+
global _settings
|
|
22
|
+
if _settings is None:
|
|
23
|
+
_settings = Settings()
|
|
24
|
+
return _settings
|
eticu/convert.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Convert a Workout model to intervals.icu workout description text.
|
|
2
|
+
|
|
3
|
+
Format notes (validated against intervals.icu documentation):
|
|
4
|
+
- Steps: "- [name] [duration] [target]"
|
|
5
|
+
- Time: "5m", "30s", "1h", "5m30s" ('m' = minutes)
|
|
6
|
+
- Distance run/ride: "2.0km" (km, rounded to 0.1 km)
|
|
7
|
+
- Distance swim: "400mtr" ('mtr' suffix for metres — 'mtr' ≠ 'm' which means minutes)
|
|
8
|
+
- HR target: "Z1 HR" (zone number + "HR" suffix)
|
|
9
|
+
- Power target: "Z1" (bare zone, power is the default target type)
|
|
10
|
+
- Repeat: "3x" on its own line; terminated by a BLANK LINE after the
|
|
11
|
+
last step in the block — without the blank line, subsequent
|
|
12
|
+
steps are interpreted as still inside the repeat.
|
|
13
|
+
|
|
14
|
+
NOTE: The ROADMAP §0.5 golden text omits the blank-line terminator. That
|
|
15
|
+
omission is corrected here based on verified intervals.icu format rules.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from eticu.models import Element, RepeatBlock, Step, Workout
|
|
23
|
+
from eticu.zones import icu_zone_label, step_label
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Duration formatting
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_time(seconds: float) -> str:
|
|
34
|
+
total = int(round(seconds))
|
|
35
|
+
h, rem = divmod(total, 3600)
|
|
36
|
+
m, s = divmod(rem, 60)
|
|
37
|
+
parts: list[str] = []
|
|
38
|
+
if h:
|
|
39
|
+
parts.append(f"{h}h")
|
|
40
|
+
if m:
|
|
41
|
+
parts.append(f"{m}m")
|
|
42
|
+
if s:
|
|
43
|
+
parts.append(f"{s}s")
|
|
44
|
+
return "".join(parts) if parts else "0s"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_distance_run(metres: float) -> str:
|
|
48
|
+
"""Round to nearest 0.1 km, emit as '2.0km'."""
|
|
49
|
+
km = round(metres / 1000, 1)
|
|
50
|
+
return f"{km:.1f}km"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_distance_swim(metres: float) -> str:
|
|
54
|
+
"""Round to nearest 25 m, emit as '400mtr' ('mtr' ≠ 'm' = minutes)."""
|
|
55
|
+
rounded = round(metres / 25) * 25
|
|
56
|
+
return f"{int(rounded)}mtr"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _format_duration(step: Step, sport: str) -> str:
|
|
60
|
+
if step.kind == "time" and step.value > 0:
|
|
61
|
+
return _format_time(step.value)
|
|
62
|
+
if step.kind == "distance" and step.value > 0:
|
|
63
|
+
if sport == "Swim":
|
|
64
|
+
return _format_distance_swim(step.value)
|
|
65
|
+
return _format_distance_run(step.value)
|
|
66
|
+
return "lap"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Target formatting
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_target(zone_8020: str | None, sport: str, cycling_power: bool) -> str:
|
|
75
|
+
icu_zone = icu_zone_label(zone_8020)
|
|
76
|
+
if sport == "Ride" and cycling_power:
|
|
77
|
+
return icu_zone # Power target: bare zone
|
|
78
|
+
return f"{icu_zone} HR" # HR target
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Step / block rendering
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_step(step: Step, sport: str, cycling_power: bool) -> str:
|
|
87
|
+
if step.zone_8020 is None and step.intensity not in ("rest", "recovery", "warmup", "cooldown"):
|
|
88
|
+
logger.warning("Step has no zone; defaulting to 8020/Z1 (Z1)")
|
|
89
|
+
label = step_label(step.zone_8020)
|
|
90
|
+
duration = _format_duration(step, sport)
|
|
91
|
+
target = _format_target(step.zone_8020, sport, cycling_power)
|
|
92
|
+
|
|
93
|
+
prefix = ""
|
|
94
|
+
if step.intensity and step.intensity not in ("active", "interval", "other"):
|
|
95
|
+
prefix = f"{step.intensity.capitalize()} "
|
|
96
|
+
|
|
97
|
+
return f"- {prefix}{label} {duration} {target}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _render_elements(
|
|
101
|
+
elements: list[Element],
|
|
102
|
+
sport: str,
|
|
103
|
+
cycling_power: bool,
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
lines: list[str] = []
|
|
106
|
+
for i, elem in enumerate(elements):
|
|
107
|
+
if isinstance(elem, Step):
|
|
108
|
+
lines.append(_render_step(elem, sport, cycling_power))
|
|
109
|
+
elif isinstance(elem, RepeatBlock):
|
|
110
|
+
if i > 0 and (not lines or lines[-1] != ""):
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append(f"{elem.count}x")
|
|
113
|
+
lines.extend(_render_elements(elem.children, sport, cycling_power))
|
|
114
|
+
# Blank line terminates the repeat block in intervals.icu format.
|
|
115
|
+
# Without it, subsequent steps are treated as inside the block.
|
|
116
|
+
if i < len(elements) - 1:
|
|
117
|
+
lines.append("")
|
|
118
|
+
return lines
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def convert(workout: Workout, cycling_power: bool = False) -> str:
|
|
122
|
+
"""Render a Workout as intervals.icu workout description text.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
workout: The parsed workout.
|
|
126
|
+
cycling_power: If True and sport is Ride, emit Power zone targets
|
|
127
|
+
instead of HR. Run/Swim always use HR regardless.
|
|
128
|
+
"""
|
|
129
|
+
lines = _render_elements(workout.elements, workout.sport, cycling_power)
|
|
130
|
+
return "\n".join(lines)
|
eticu/download.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Download .FIT files to a local cache directory.
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
- Resumable: skips files that already exist and are non-empty.
|
|
5
|
+
- Atomic writes: download to a .part file, then rename.
|
|
6
|
+
- Modest concurrency with a configurable thread count.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import unquote, urlparse
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_HEADERS = {
|
|
22
|
+
"User-Agent": (
|
|
23
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
24
|
+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def local_path(url: str, cache_dir: Path) -> Path:
|
|
30
|
+
"""Map a .FIT URL to a local path, preserving the sport subfolder."""
|
|
31
|
+
parts = [unquote(p) for p in urlparse(url).path.split("/") if p]
|
|
32
|
+
tail = parts[-2:] if len(parts) >= 2 else parts[-1:]
|
|
33
|
+
return cache_dir.joinpath(*tail)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _download_one(
|
|
37
|
+
client: httpx.Client,
|
|
38
|
+
url: str,
|
|
39
|
+
cache_dir: Path,
|
|
40
|
+
retries: int = 3,
|
|
41
|
+
) -> tuple[str, str]:
|
|
42
|
+
"""Download a single file; return (url, status) where status ∈ ok/skip/error:…"""
|
|
43
|
+
dest = local_path(url, cache_dir)
|
|
44
|
+
if dest.exists() and dest.stat().st_size > 0:
|
|
45
|
+
return url, "skip"
|
|
46
|
+
|
|
47
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
for attempt in range(1, retries + 1):
|
|
49
|
+
try:
|
|
50
|
+
with client.stream("GET", url, timeout=60) as r:
|
|
51
|
+
r.raise_for_status()
|
|
52
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
53
|
+
with open(tmp, "wb") as f:
|
|
54
|
+
for chunk in r.iter_bytes(chunk_size=64 * 1024):
|
|
55
|
+
f.write(chunk)
|
|
56
|
+
tmp.replace(dest)
|
|
57
|
+
return url, "ok"
|
|
58
|
+
except httpx.HTTPError as exc:
|
|
59
|
+
if attempt == retries:
|
|
60
|
+
return url, f"error: {exc}"
|
|
61
|
+
time.sleep(2 * attempt)
|
|
62
|
+
return url, "error: exhausted retries"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def download_all(
|
|
66
|
+
urls: list[str],
|
|
67
|
+
cache_dir: Path,
|
|
68
|
+
*,
|
|
69
|
+
workers: int = 4,
|
|
70
|
+
dry_run: bool = False,
|
|
71
|
+
) -> dict[str, int]:
|
|
72
|
+
"""Download a list of .FIT URLs into cache_dir.
|
|
73
|
+
|
|
74
|
+
Returns counts: {"ok": N, "skip": N, "error": N}.
|
|
75
|
+
"""
|
|
76
|
+
counts = {"ok": 0, "skip": 0, "error": 0}
|
|
77
|
+
if not urls:
|
|
78
|
+
return counts
|
|
79
|
+
|
|
80
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
if dry_run:
|
|
83
|
+
for url in urls:
|
|
84
|
+
dest = local_path(url, cache_dir)
|
|
85
|
+
status = "skip" if (dest.exists() and dest.stat().st_size > 0) else "would-download"
|
|
86
|
+
logger.info("[DRY-RUN] %s %s", status, dest)
|
|
87
|
+
|
|
88
|
+
def _needs_download(u: str) -> bool:
|
|
89
|
+
p = local_path(u, cache_dir)
|
|
90
|
+
return not (p.exists() and p.stat().st_size > 0)
|
|
91
|
+
|
|
92
|
+
counts["ok"] = sum(1 for u in urls if _needs_download(u))
|
|
93
|
+
counts["skip"] = len(urls) - counts["ok"]
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
with (
|
|
97
|
+
httpx.Client(headers=_HEADERS, follow_redirects=True) as client,
|
|
98
|
+
ThreadPoolExecutor(max_workers=workers) as pool,
|
|
99
|
+
):
|
|
100
|
+
futures = {pool.submit(_download_one, client, u, cache_dir): u for u in urls}
|
|
101
|
+
for i, fut in enumerate(as_completed(futures), 1):
|
|
102
|
+
url, status = fut.result()
|
|
103
|
+
dest = local_path(url, cache_dir)
|
|
104
|
+
rel = dest.relative_to(cache_dir) if dest.is_relative_to(cache_dir) else dest
|
|
105
|
+
if status == "ok":
|
|
106
|
+
counts["ok"] += 1
|
|
107
|
+
logger.info("[%4d/%d] OK %s", i, len(urls), rel)
|
|
108
|
+
elif status == "skip":
|
|
109
|
+
counts["skip"] += 1
|
|
110
|
+
logger.debug("[%4d/%d] SKIP %s", i, len(urls), rel)
|
|
111
|
+
else:
|
|
112
|
+
counts["error"] += 1
|
|
113
|
+
logger.error("[%4d/%d] FAIL %s (%s)", i, len(urls), rel, status)
|
|
114
|
+
|
|
115
|
+
return counts
|