import json import logging import os import tempfile from datetime import timezone as dt_timezone from math import asin, cos, radians, sin, sqrt from typing import Optional from django.conf import settings from django.contrib.auth import get_user_model from django.core.files import File from locations.models import GeoLocation from scrobbles.models import Scrobble from trails.models import Trail, TrailLogData logger = logging.getLogger(__name__) User = get_user_model() def haversine(lat1, lon1, lat2, lon2): R = 6371000 dlat = radians(lat2 - lat1) dlon = radians(lon2 - lon1) a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 return R * 2 * asin(sqrt(a)) def compute_trail_stats(points): distance_m = 0.0 elevation_gain = 0.0 first_time = None last_time = None for i in range(1, len(points)): lat1, lon1, ele1, t1 = points[i - 1] lat2, lon2, ele2, t2 = points[i] if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None: distance_m += haversine(lat1, lon1, lat2, lon2) if ele1 is not None and ele2 is not None and ele2 > ele1: elevation_gain += ele2 - ele1 if first_time is None and t1 is not None: first_time = t1 if t2 is not None: last_time = t2 moving_time = ( (last_time - first_time).total_seconds() if first_time and last_time else 0 ) avg_speed_kmh = (distance_m / 1000) / (moving_time / 3600) if moving_time > 0 else 0.0 return { "distance_km": round(distance_m / 1000, 2), "elevation_gain_m": round(elevation_gain, 1), "moving_time_seconds": int(moving_time), "avg_speed_kmh": round(avg_speed_kmh, 2), } def parse_trackpoints(file_path): _, ext = os.path.splitext(file_path) ext = ext.lower() if ext == ".gpx": return _parse_gpx(file_path) elif ext == ".fit": return _parse_fit(file_path) else: raise ValueError(f"Unsupported file type: {ext}") def _parse_gpx(file_path): import gpxpy with open(file_path) as f: gpx = gpxpy.parse(f) points = [] track_name = None description = None extra = {} for track in gpx.tracks: if track.name and not track_name: track_name = track.name if track.description and not description: description = track.description if track.comment and not extra: try: cmt_data = json.loads(track.comment) data = cmt_data.get("activity_dict") or cmt_data hr_avg = data.get("average_heartrate") if hr_avg is not None: extra["avg_heartrate"] = int(round(hr_avg)) hr_max = data.get("max_heartrate") if hr_max is not None: extra["max_heartrate"] = int(round(hr_max)) extra["calories"] = data.get("calories") extra["moving_time_seconds"] = data.get("moving_time") extra["total_elevation_gain_m"] = data.get("total_elevation_gain") avg_speed = data.get("average_speed") if avg_speed is not None: extra["avg_speed_kmh"] = round(avg_speed * 3.6, 2) extra["activity_type"] = cmt_data.get("type") or data.get("type") except (json.JSONDecodeError, TypeError): pass for seg in track.segments: for pt in seg.points: points.append((pt.latitude, pt.longitude, pt.elevation, pt.time)) return { "points": points, "name": track_name or os.path.splitext(os.path.basename(file_path))[0], "description": description, "extra": extra, } def _parse_fit(file_path): import fitparse fitfile = fitparse.FitFile(file_path) messages = list(fitfile.get_messages("record")) points = [] track_name = os.path.splitext(os.path.basename(file_path))[0] extra = {} session_msgs = list(fitfile.get_messages("session")) for msg in session_msgs: for field in msg: if field.name == "sport" and field.value: track_name = str(field.value) elif field.name == "total_distance" and field.value: extra["distance_km"] = round(field.value / 1000, 2) elif field.name == "total_timer_time" and field.value: extra["moving_time_seconds"] = int(field.value) elif field.name == "total_elapsed_time" and field.value: extra.setdefault("moving_time_seconds", int(field.value)) elif field.name == "total_ascent" and field.value: extra["total_elevation_gain_m"] = field.value elif field.name == "avg_heart_rate" and field.value: extra["avg_heartrate"] = int(round(field.value)) elif field.name == "max_heart_rate" and field.value: extra["max_heartrate"] = int(round(field.value)) elif field.name == "avg_speed" and field.value: extra["avg_speed_kmh"] = round(field.value * 3.6, 2) elif field.name == "total_calories" and field.value: extra["calories"] = int(field.value) for msg in messages: lat = None lon = None ele = None t = None for field in msg: if field.name == "position_lat": lat = field.value * (180.0 / (2**31)) if field.value else None elif field.name == "position_long": lon = field.value * (180.0 / (2**31)) if field.value else None elif field.name == "altitude": ele = field.value elif field.name == "timestamp": t = field.value.replace(tzinfo=dt_timezone.utc) if field.value else None if lat is not None and lon is not None: points.append((lat, lon, ele, t)) return { "points": points, "name": track_name, "description": None, "extra": extra, } def convert_fit_to_gpx(file_path): import gpxpy.gpx result = _parse_fit(file_path) points = result["points"] track_name = result["name"] gpx = gpxpy.gpx.GPX() gpx_track = gpxpy.gpx.GPXTrack(name=track_name) gpx.tracks.append(gpx_track) gpx_seg = gpxpy.gpx.GPXTrackSegment() gpx_track.segments.append(gpx_seg) for lat, lon, ele, t in points: gpx_seg.points.append(gpxpy.gpx.GPXTrackPoint(lat, lon, elevation=ele, time=t)) tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".gpx", delete=False) tmp.write(gpx.to_xml()) tmp.close() return tmp.name def import_trail_gpx(file_path, user_id, original_filename=None): user = User.objects.get(id=user_id) new_scrobbles = [] result = parse_trackpoints(file_path) points = result["points"] track_name = result["name"] description = result.get("description") extra = result.get("extra", {}) if not points: logger.warning(f"No trackpoints found in {file_path}") return [] first_lat, first_lon, _, first_time = points[0] _, _, _, last_time = points[-1] if first_time is None: logger.warning(f"No timestamps in {file_path}") return [] geo, _ = GeoLocation.objects.get_or_create( lat=round(first_lat, 6), lon=round(first_lon, 6), defaults={"altitude": None}, ) trail = Trail.find_by_trailhead(first_lat, first_lon, tolerance_m=100) if not trail: trail = Trail.find_or_create(track_name) trail.trailhead_location = geo trail.save(update_fields=["trailhead_location"]) timestamp = first_time stop_timestamp = last_time existing = Scrobble.objects.filter( timestamp=timestamp, trail=trail, user=user, ).first() if existing: logger.debug(f"Skipping existing scrobble for trail {trail}") return [] computed = compute_trail_stats(points) logdata = TrailLogData( description=description, distance_km=computed["distance_km"], elevation_gain_m=computed["elevation_gain_m"], moving_time_seconds=computed["moving_time_seconds"], activity_type=extra.get("activity_type"), avg_speed_kmh=computed["avg_speed_kmh"], avg_heartrate=extra.get("avg_heartrate"), max_heartrate=extra.get("max_heartrate"), calories=extra.get("calories"), ) duration = int((stop_timestamp - timestamp).total_seconds()) if stop_timestamp else 0 scrobble = Scrobble( user=user, timestamp=timestamp, stop_timestamp=stop_timestamp, playback_position_seconds=duration, source="GPX Import", trail=trail, log=logdata.asdict, timezone=user.profile.timezone or settings.TIME_ZONE, played_to_completion=True, in_progress=False, media_type=Scrobble.MediaType.TRAIL, ) _, ext = os.path.splitext(file_path) if ext.lower() == ".fit": gpx_path = convert_fit_to_gpx(file_path) with open(gpx_path, "rb") as f: scrobble.gpx_file.save( f"{original_filename or 'trail'}.gpx", File(f), save=False, ) os.unlink(gpx_path) else: with open(file_path, "rb") as f: scrobble.gpx_file.save( original_filename or os.path.basename(file_path), File(f), save=False, ) new_scrobbles.append(scrobble) created = Scrobble.objects.bulk_create(new_scrobbles) logger.info(f"Created {len(created)} trail scrobbles") return created