Files
zwift-offline/local_zwift_workouts.py
T
sumulige 2cae3c67b2 feat: maintain local Zwift workout catalog
Write synced workouts into the local Zwift Workouts directory and maintain the matching workouts.files manifest entries so the Zwift client can discover generated .zwo files.

Apply the same local catalog update and cleanup path to Intervals.icu and TrainingPeaks managed workouts, while preserving unmanaged custom workout manifest entries.

Expose a small launcher status readout for active provider, cached workout metadata, local file presence, and manifest health.

Use a configurable ZWIFT_WORKOUTS_DIR Docker Compose bind mount instead of committing a host-specific path or credentials.

Privacy review: no secrets, tokens, account IDs, or hard-coded local user paths are added. Existing credential references are code variables only.

Verification: .venv/bin/python -m unittest discover -s tests; git diff --cached --check; docker compose config.
2026-05-23 22:12:03 +08:00

169 lines
5.7 KiB
Python

import hashlib
import os
import xml.etree.ElementTree as ET
MANIFEST_FILENAME = 'workouts.files'
def workouts_dir(root_dir, player_id):
return os.path.join(root_dir, str(player_id))
def manifest_file(root_dir, player_id):
return os.path.join(workouts_dir(root_dir, player_id), MANIFEST_FILENAME)
def parse_int(value):
try:
return int((value or '0').strip() or '0')
except ValueError:
return 0
def file_checksum(content):
return (-sum(content)) % 256
def file_guid(filename):
digest = hashlib.sha1(filename.encode('utf-8')).digest()
return (int.from_bytes(digest[:4], 'big') & 0x7fffffff) or 1
def load_manifest(root_dir, player_id):
path = manifest_file(root_dir, player_id)
if not os.path.exists(path):
return []
try:
root = ET.parse(path).getroot()
except ET.ParseError:
return []
entries = []
for node in root.findall('custom_file'):
name = (node.findtext('name') or '').strip()
if not name:
continue
entries.append({
'name': name,
'time': parse_int(node.findtext('time')),
'guid': parse_int(node.findtext('guid')),
'checksum': parse_int(node.findtext('checksum')),
'deleted': (node.findtext('deleted') or 'false').strip().lower() == 'true',
})
return entries
def save_manifest(root_dir, player_id, entries):
directory = workouts_dir(root_dir, player_id)
os.makedirs(directory, exist_ok=True)
root = ET.Element('custom_file_directory')
for entry in sorted(entries, key=lambda item: item['name'].lower()):
node = ET.SubElement(root, 'custom_file')
ET.SubElement(node, 'name').text = entry['name']
ET.SubElement(node, 'time').text = str(int(entry['time']))
ET.SubElement(node, 'guid').text = str(int(entry['guid']))
ET.SubElement(node, 'checksum').text = str(int(entry['checksum']))
ET.SubElement(node, 'deleted').text = 'true' if entry.get('deleted') else 'false'
ET.SubElement(root, 'deleted_files')
tree = ET.ElementTree(root)
ET.indent(tree, space=' ')
path = manifest_file(root_dir, player_id)
tree.write(path, encoding='utf-8', xml_declaration=False)
with open(path, 'a', encoding='utf-8') as fd:
fd.write('\n')
return path
def upsert_manifest_entry(root_dir, player_id, filename, content, timestamp=None):
entries = [entry for entry in load_manifest(root_dir, player_id) if entry['name'] != filename]
timestamp = int(timestamp if timestamp is not None else os.path.getmtime(os.path.join(workouts_dir(root_dir, player_id), filename)))
entry = {
'name': filename,
'time': timestamp,
'guid': file_guid(filename),
'checksum': file_checksum(content),
'deleted': False,
}
entries.append(entry)
save_manifest(root_dir, player_id, entries)
return entry
def remove_manifest_entries(root_dir, player_id, names):
names = set(names)
if not names:
return 0
existing = load_manifest(root_dir, player_id)
remaining = [entry for entry in existing if entry['name'] not in names]
removed = len(existing) - len(remaining)
if removed or os.path.exists(manifest_file(root_dir, player_id)):
save_manifest(root_dir, player_id, remaining)
return removed
def export_workout(root_dir, player_id, filename, content):
directory = workouts_dir(root_dir, player_id)
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, filename)
with open(path, 'wb') as fd:
fd.write(content)
timestamp = int(os.path.getmtime(path))
manifest_entry = upsert_manifest_entry(root_dir, player_id, filename, content, timestamp=timestamp)
return {
'path': path,
'manifest_path': manifest_file(root_dir, player_id),
'manifest_entry': manifest_entry,
}
def remove_prefixed_workouts(root_dir, player_id, prefixes):
directory = workouts_dir(root_dir, player_id)
if not os.path.isdir(directory):
return {'removed_files': 0, 'removed_manifest_entries': 0}
names_to_remove = []
removed_files = 0
for name in os.listdir(directory):
if name == MANIFEST_FILENAME or not any(name.startswith(prefix) for prefix in prefixes):
continue
path = os.path.join(directory, name)
if not os.path.isfile(path):
continue
os.remove(path)
names_to_remove.append(name)
removed_files += 1
removed_manifest_entries = remove_manifest_entries(root_dir, player_id, names_to_remove)
return {
'removed_files': removed_files,
'removed_manifest_entries': removed_manifest_entries,
}
def health_report(root_dir, player_id, filename):
directory = workouts_dir(root_dir, player_id)
path = os.path.join(directory, filename)
entries = {entry['name']: entry for entry in load_manifest(root_dir, player_id)}
entry = entries.get(filename)
report = {
'directory': directory,
'path': path,
'manifest_path': manifest_file(root_dir, player_id),
'file_exists': os.path.exists(path),
'manifest_entry_exists': entry is not None,
'manifest_entry': entry,
}
if report['file_exists']:
with open(path, 'rb') as fd:
content = fd.read()
report['expected_checksum'] = file_checksum(content)
report['expected_guid'] = file_guid(filename)
else:
report['expected_checksum'] = None
report['expected_guid'] = file_guid(filename)
report['healthy'] = (
report['file_exists']
and report['manifest_entry_exists']
and entry.get('checksum') == report['expected_checksum']
and int(entry.get('guid') or 0) > 0
) if entry else False
return report