diff --git a/vrobbler/apps/locations/management/__init__.py b/vrobbler/apps/locations/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/locations/management/commands/__init__.py b/vrobbler/apps/locations/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/locations/management/commands/retroactive_movement_detection.py b/vrobbler/apps/locations/management/commands/retroactive_movement_detection.py new file mode 100644 index 0000000..c20d602 --- /dev/null +++ b/vrobbler/apps/locations/management/commands/retroactive_movement_detection.py @@ -0,0 +1,113 @@ +import logging +from django.core.management.base import BaseCommand +from scrobbles.models import Scrobble +from locations.utils import detect_movement + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Run movement detection on all location scrobbles retroactively" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be updated without making changes", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Number of scrobbles to process at once (default: 100)", + ) + parser.add_argument( + "--user-id", + type=int, + help="Only process scrobbles for a specific user ID", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + batch_size = options["batch_size"] + user_id = options.get("user_id") + + # Get all location scrobbles ordered by timestamp + queryset = Scrobble.objects.filter( + media_type=Scrobble.MediaType.GEO_LOCATION, + ).order_by("timestamp") + + if user_id: + queryset = queryset.filter(user_id=user_id) + + total_count = queryset.count() + self.stdout.write(f"Processing {total_count} location scrobbles...") + + processed = 0 + updated = 0 + errors = 0 + + # Track previous scrobble per user for calculated speed + previous_scrobbles = {} + + for scrobble in queryset.iterator(chunk_size=batch_size): + try: + if not scrobble.geo_location: + processed += 1 + continue + + # Get previous scrobble for this user + prev = previous_scrobbles.get(scrobble.user_id) + + # Run movement detection + movement_data = detect_movement(scrobble, prev) + + # Update previous scrobble tracker + previous_scrobbles[scrobble.user_id] = scrobble + + # Check if we need to update + log = scrobble.log or {} + existing_detection = log.get("movement_detection", {}) + + if existing_detection != movement_data: + if not dry_run: + # Store movement data in log + scrobble.log["movement_detection"] = movement_data + scrobble.save(update_fields=["log"]) + + updated += 1 + self.stdout.write( + f"[{processed}/{total_count}] " + f"User {scrobble.user_id}: " + f"{movement_data['movement_type']} " + f"({movement_data['confidence']} confidence, " + f"{movement_data['detection_method']})" + ) + else: + self.stdout.write(f"[{processed}/{total_count}] No change needed") + + processed += 1 + + if processed % 100 == 0: + self.stdout.write( + f"Progress: {processed}/{total_count} " + f"({processed * 100 // total_count}%)" + ) + + except Exception as e: + errors += 1 + logger.error( + f"Error processing scrobble {scrobble.id}: {e}", + exc_info=True, + ) + self.stderr.write(f"Error processing scrobble {scrobble.id}: {e}") + + self.stdout.write( + self.style.SUCCESS( + f"Done! Processed: {processed}, " + f"Updated: {updated}, Errors: {errors}" + ) + ) + + if dry_run: + self.stdout.write(self.style.WARNING("Dry run - no changes were made")) diff --git a/vrobbler/apps/locations/models.py b/vrobbler/apps/locations/models.py index 0af7dfa..6f44771 100644 --- a/vrobbler/apps/locations/models.py +++ b/vrobbler/apps/locations/models.py @@ -13,14 +13,20 @@ from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin logger = logging.getLogger(__name__) BNULL = {"blank": True, "null": True} User = get_user_model() - GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4)) GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001")) @dataclass class GeoLocationLogData(BaseLogData, WithPeopleLogData): - pass + is_moving: bool = False + movement_type: str = "unknown" + confidence: str = "low" + speed_mps: float = 0.0 + speed_accuracy: float = 0.0 + detection_method: str = "unknown" + activity: str = "" + detected_at: str = "" class GeoLocation(ScrobblableMixin): diff --git a/vrobbler/apps/locations/utils.py b/vrobbler/apps/locations/utils.py new file mode 100644 index 0000000..0b15de4 --- /dev/null +++ b/vrobbler/apps/locations/utils.py @@ -0,0 +1,198 @@ +import logging +from typing import Optional + +from django.utils import timezone +from scrobbles.models import Scrobble + +logger = logging.getLogger(__name__) + +# Speed thresholds in meters per second +SPEED_THRESHOLDS = { + "at_rest": 0.5, + "walking": 1.5, + "running": 3.5, + "bicycling": 9.0, + "driving": float("inf"), +} + +# Activity mapping from Android activity recognition +ACTIVITY_MAPPING = { + "still": "at_rest", + "on_foot": "walking", + "walking": "walking", + "running": "running", + "on_bicycle": "bicycling", + "in_vehicle": "driving", + "unknown": "unknown", +} + + +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate the great-circle distance between two points in meters.""" + from math import radians, cos, sin, asin, sqrt + + lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 + c = 2 * asin(sqrt(a)) + r = 6371000 # Radius of earth in meters + return c * r + + +def detect_movement( + current_scrobble: Scrobble, + previous_scrobble: Optional[Scrobble] = None, +) -> dict: + """ + Analyze location scrobble to determine if moving and mode of transport. + + Returns a dict shaped like GeoLocationLogData that can be stored in scrobble.log: + { + "is_moving": bool, + "movement_type": str, + "confidence": str, + "speed_mps": float, + "speed_accuracy": float, + "detection_method": str, + "activity": str, + "detected_at": str, + } + """ + + result = { + "is_moving": False, + "movement_type": "unknown", + "confidence": "low", + "speed_mps": 0.0, + "speed_accuracy": 0.0, + "detection_method": "unknown", + "activity": "", + "detected_at": timezone.now().isoformat(), + } + + if current_scrobble.media_type != Scrobble.MediaType.GEO_LOCATION: + return result + + # Get GPS data from log + log_data = current_scrobble.log or {} + gps_data = log_data.get("gps_data", {}) + + # Method 1: Check Android activity recognition (highest confidence) + activity = gps_data.get("activity", "") + if activity and activity in ACTIVITY_MAPPING: + movement_type = ACTIVITY_MAPPING.get(activity, "unknown") + result.update( + { + "is_moving": movement_type != "at_rest", + "movement_type": movement_type, + "confidence": "high", + "detection_method": "activity_recognition", + "activity": activity, + } + ) + return result + + # Method 2: Use speed from GPS + speed = gps_data.get("speed") + accuracy = gps_data.get("accuracy", 100.0) + + if speed is not None: + try: + speed = float(speed) + result["speed_mps"] = speed + result["speed_accuracy"] = float(accuracy) + + # Determine movement type from speed + movement_type = "at_rest" + is_moving = False + + if speed >= SPEED_THRESHOLDS["walking"]: + movement_type = "walking" + is_moving = True + if speed >= SPEED_THRESHOLDS["running"]: + movement_type = "running" + is_moving = True + if speed >= SPEED_THRESHOLDS["bicycling"]: + movement_type = "bicycling" + is_moving = True + if speed >= SPEED_THRESHOLDS["driving"]: + movement_type = "driving" + is_moving = True + + # Confidence based on GPS accuracy + confidence = "low" + if accuracy <= 10: + confidence = "high" + elif accuracy <= 30: + confidence = "medium" + + result.update( + { + "is_moving": is_moving, + "movement_type": movement_type, + "confidence": confidence, + "detection_method": "gps_speed", + } + ) + return result + except (ValueError, TypeError): + pass + + # Method 3: Calculate speed from distance/time between scrobbles + if not previous_scrobble: + # Try to get previous scrobble from the same user + previous_scrobble = ( + Scrobble.objects.filter( + user=current_scrobble.user, + media_type=Scrobble.MediaType.GEO_LOCATION, + timestamp__lt=current_scrobble.timestamp, + ) + .order_by("-timestamp") + .first() + ) + + if previous_scrobble and previous_scrobble.geo_location: + time_diff = ( + current_scrobble.timestamp - previous_scrobble.timestamp + ).total_seconds() + + if time_diff > 0: + distance = haversine_distance( + previous_scrobble.geo_location.lat, + previous_scrobble.geo_location.lon, + current_scrobble.geo_location.lat, + current_scrobble.geo_location.lon, + ) + speed = distance / time_diff + result["speed_mps"] = round(speed, 2) + + # Determine movement type from calculated speed + movement_type = "at_rest" + is_moving = False + + if speed >= SPEED_THRESHOLDS["walking"]: + movement_type = "walking" + is_moving = True + if speed >= SPEED_THRESHOLDS["running"]: + movement_type = "running" + is_moving = True + if speed >= SPEED_THRESHOLDS["bicycling"]: + movement_type = "bicycling" + is_moving = True + if speed >= SPEED_THRESHOLDS["driving"]: + movement_type = "driving" + is_moving = True + + # Lower confidence for calculated speed + result.update( + { + "is_moving": is_moving, + "movement_type": movement_type, + "confidence": "low", + "detection_method": "calculated_speed", + } + ) + return result + + return result diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 810af54..2feaae3 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -1034,6 +1034,8 @@ def manual_scrobble_twitch_channel( def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble: + from locations.utils import detect_movement + location = GeoLocation.find_or_create(data_dict) timestamp = pendulum.parse(data_dict.get("time", timezone.now())) @@ -1052,6 +1054,24 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble: provider = LOCATION_PROVIDERS[data_dict.get("prov")] + # Store GPS data in log + if "gps_data" not in scrobble.log.keys(): + scrobble.log["gps_data"] = {} + + gps_data = { + "speed": data_dict.get("speed"), + "accuracy": data_dict.get("accuracy"), + "direction": data_dict.get("direction"), + "activity": data_dict.get("activity"), + "provider": provider, + "battery": data_dict.get("battery"), + } + scrobble.log["gps_data"] = gps_data + + # Run movement detection using utils function + movement_data = detect_movement(scrobble) + scrobble.log["movement_detection"] = movement_data + if "gps_updates" not in scrobble.log.keys(): scrobble.log["gps_updates"] = [] @@ -1076,6 +1096,7 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble: "timestamp": extra_data.get("timestamp"), "raw_timestamp": data_dict.get("time"), "media_type": Scrobble.MediaType.GEO_LOCATION, + "movement_data": movement_data, }, )