From fc64dfadba9a821312093fc7d327d629db6de309 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Thu, 23 Nov 2023 00:58:11 +0100 Subject: [PATCH] Add basic location tracking --- vrobbler/apps/locations/__init__.py | 0 vrobbler/apps/locations/admin.py | 35 ++++++++ .../apps/locations/migrations/0001_initial.py | 77 ++++++++++++++++ .../migrations/0002_rawgeolocation.py | 58 ++++++++++++ .../0003_rawgeolocation_timestamp.py | 18 ++++ .../apps/locations/migrations/__init__.py | 0 vrobbler/apps/locations/models.py | 88 +++++++++++++++++++ vrobbler/apps/locations/views.py | 1 + ..._geo_location_alter_scrobble_media_type.py | 43 +++++++++ vrobbler/apps/scrobbles/models.py | 25 +++++- vrobbler/apps/scrobbles/scrobblers.py | 39 ++++++++ vrobbler/apps/scrobbles/urls.py | 5 ++ vrobbler/apps/scrobbles/views.py | 23 +++++ vrobbler/settings.py | 1 + 14 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 vrobbler/apps/locations/__init__.py create mode 100644 vrobbler/apps/locations/admin.py create mode 100644 vrobbler/apps/locations/migrations/0001_initial.py create mode 100644 vrobbler/apps/locations/migrations/0002_rawgeolocation.py create mode 100644 vrobbler/apps/locations/migrations/0003_rawgeolocation_timestamp.py create mode 100644 vrobbler/apps/locations/migrations/__init__.py create mode 100644 vrobbler/apps/locations/models.py create mode 100644 vrobbler/apps/locations/views.py create mode 100644 vrobbler/apps/scrobbles/migrations/0044_scrobble_geo_location_alter_scrobble_media_type.py diff --git a/vrobbler/apps/locations/__init__.py b/vrobbler/apps/locations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/locations/admin.py b/vrobbler/apps/locations/admin.py new file mode 100644 index 0000000..28ef572 --- /dev/null +++ b/vrobbler/apps/locations/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin + +from locations.models import GeoLocation, RawGeoLocation + +from scrobbles.admin import ScrobbleInline + + +@admin.register(GeoLocation) +class GeoLocationAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "lat", + "lon", + "title", + "altitude", + ) + ordering = ( + "lat", + "lon", + ) + + +@admin.register(RawGeoLocation) +class RawGeoLocationAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "lat", + "lon", + "altitude", + "speed", + ) + ordering = ( + "lat", + "lon", + ) diff --git a/vrobbler/apps/locations/migrations/0001_initial.py b/vrobbler/apps/locations/migrations/0001_initial.py new file mode 100644 index 0000000..918b52e --- /dev/null +++ b/vrobbler/apps/locations/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 4.1.7 on 2023-11-21 23:29 + +from django.db import migrations, models +import django_extensions.db.fields +import taggit.managers +import uuid + + +class Migration(migrations.Migration): + + initial = True + + operations = [ + migrations.CreateModel( + name="GeoLocation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "title", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "run_time_seconds", + models.IntegerField(blank=True, null=True), + ), + ( + "run_time_ticks", + models.PositiveBigIntegerField(blank=True, null=True), + ), + ( + "uuid", + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ("lat", models.FloatField()), + ("lon", models.FloatField()), + ("altitude", models.FloatField(blank=True, null=True)), + ( + "genre", + taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="scrobbles.ObjectWithGenres", + to="scrobbles.Genre", + verbose_name="Tags", + ), + ), + ], + options={ + "unique_together": {("lat", "lon", "altitude")}, + }, + ), + ] diff --git a/vrobbler/apps/locations/migrations/0002_rawgeolocation.py b/vrobbler/apps/locations/migrations/0002_rawgeolocation.py new file mode 100644 index 0000000..9e7886d --- /dev/null +++ b/vrobbler/apps/locations/migrations/0002_rawgeolocation.py @@ -0,0 +1,58 @@ +# Generated by Django 4.1.7 on 2023-11-22 00:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("locations", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="RawGeoLocation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ("lat", models.FloatField()), + ("lon", models.FloatField()), + ("altitude", models.FloatField(blank=True, null=True)), + ("speed", models.FloatField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + ] diff --git a/vrobbler/apps/locations/migrations/0003_rawgeolocation_timestamp.py b/vrobbler/apps/locations/migrations/0003_rawgeolocation_timestamp.py new file mode 100644 index 0000000..4bc0ff0 --- /dev/null +++ b/vrobbler/apps/locations/migrations/0003_rawgeolocation_timestamp.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-11-22 23:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("locations", "0002_rawgeolocation"), + ] + + operations = [ + migrations.AddField( + model_name="rawgeolocation", + name="timestamp", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/vrobbler/apps/locations/migrations/__init__.py b/vrobbler/apps/locations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/locations/models.py b/vrobbler/apps/locations/models.py new file mode 100644 index 0000000..abaaaf3 --- /dev/null +++ b/vrobbler/apps/locations/models.py @@ -0,0 +1,88 @@ +import logging +from typing import Dict +from uuid import uuid4 + +from django.contrib.auth import get_user_model +from django.conf import settings +from django.db import models +from django.urls import reverse +from django_extensions.db.models import TimeStampedModel +from scrobbles.mixins import ScrobblableMixin + +logger = logging.getLogger(__name__) +BNULL = {"blank": True, "null": True} +User = get_user_model() + + +class GeoLocation(ScrobblableMixin): + COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100) + + uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) + lat = models.FloatField() + lon = models.FloatField() + altitude = models.FloatField(**BNULL) + + class Meta: + unique_together = [["lat", "lon", "altitude"]] + + def __str__(self): + return f"{self.lat} x {self.lon}" + + def get_absolute_url(self): + return reverse( + "locations:geo_location_detail", kwargs={"slug": self.uuid} + ) + + @property + def truncated_lat(self): + return float(str(self.lat)[:-3]) + + @property + def truncated_lan(self): + return float(str(self.lon)[:-3]) + + @classmethod + def find_or_create(cls, data_dict: Dict) -> "GeoLocation": + """Given a data dict from GPSLogger, does the heavy lifting of looking up + the location, creating if if doesn't exist yet. + + """ + # TODO Add constants for all these data keys + if "lat" not in data_dict.keys() or "lon" not in data_dict.keys(): + logger.error("No lat or lon keys in data dict") + return + + lat_int, lat_places = data_dict.get("lat", "").split(".") + lon_int, lon_places = data_dict.get("lon", "").split(".") + alt_int, alt_places = data_dict.get("alt", "").split(".") + + truncated_lat = lat_places[0:4] + truncated_lon = lon_places[0:4] + truncated_alt = alt_places[0:3] + + data_dict["lat"] = float(".".join([lat_int, truncated_lat])) + data_dict["lon"] = float(".".join([lon_int, truncated_lon])) + data_dict["altitude"] = float(".".join([alt_int, truncated_alt])) + + location = cls.objects.filter( + lat=data_dict.get("lat"), + lon=data_dict.get("lon"), + altitude=data_dict.get("alt"), + ).first() + + if not location: + location = cls.objects.create( + lat=data_dict.get("lat"), + lon=data_dict.get("lon"), + altitude=data_dict.get("alt"), + ) + return location + + +class RawGeoLocation(TimeStampedModel): + user = models.ForeignKey(User, on_delete=models.CASCADE) + lat = models.FloatField() + lon = models.FloatField() + altitude = models.FloatField(**BNULL) + speed = models.FloatField(**BNULL) + timestamp = models.DateTimeField(**BNULL) diff --git a/vrobbler/apps/locations/views.py b/vrobbler/apps/locations/views.py new file mode 100644 index 0000000..e5a0d9b --- /dev/null +++ b/vrobbler/apps/locations/views.py @@ -0,0 +1 @@ +#!/usr/bin/env python3 diff --git a/vrobbler/apps/scrobbles/migrations/0044_scrobble_geo_location_alter_scrobble_media_type.py b/vrobbler/apps/scrobbles/migrations/0044_scrobble_geo_location_alter_scrobble_media_type.py new file mode 100644 index 0000000..81a97bd --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0044_scrobble_geo_location_alter_scrobble_media_type.py @@ -0,0 +1,43 @@ +# Generated by Django 4.1.7 on 2023-11-21 23:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("locations", "0001_initial"), + ("scrobbles", "0043_scrobbledpage"), + ] + + operations = [ + migrations.AddField( + model_name="scrobble", + name="geo_location", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="locations.geolocation", + ), + ), + migrations.AlterField( + model_name="scrobble", + name="media_type", + field=models.CharField( + choices=[ + ("Video", "Video"), + ("Track", "Track"), + ("Episode", "Podcast episode"), + ("SportEvent", "Sport event"), + ("Book", "Book"), + ("VideoGame", "Video game"), + ("BoardGame", "Board game"), + ("GeoLocation", "GeoLocation"), + ], + default="Video", + max_length=14, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 52d13cf..7cfd748 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -34,6 +34,7 @@ from sports.models import SportEvent from videogames import retroarch from videogames.models import VideoGame from videos.models import Series, Video +from locations.models import GeoLocation logger = logging.getLogger(__name__) User = get_user_model() @@ -469,6 +470,7 @@ class Scrobble(TimeStampedModel): BOOK = "Book", "Book" VIDEO_GAME = "VideoGame", "Video game" BOARD_GAME = "BoardGame", "Board game" + GEO_LOCATION = "GeoLocation", "GeoLocation" uuid = models.UUIDField(editable=False, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) @@ -486,6 +488,9 @@ class Scrobble(TimeStampedModel): board_game = models.ForeignKey( BoardGame, on_delete=models.DO_NOTHING, **BNULL ) + geo_location = models.ForeignKey( + GeoLocation, on_delete=models.DO_NOTHING, **BNULL + ) media_type = models.CharField( max_length=14, choices=MediaType.choices, default=MediaType.VIDEO ) @@ -612,6 +617,7 @@ class Scrobble(TimeStampedModel): @property def can_be_updated(self) -> bool: updatable = True + if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values(): logger.info(f"No - Long play media") updatable = False @@ -621,6 +627,17 @@ class Scrobble(TimeStampedModel): if self.is_stale: logger.info(f"No - stale - {self.id} - {self.source}") updatable = False + if self.media_obj.__class__.__name__ in ["GeoLocation"]: + logger.info(f"Calculate proximity to last scrobble") + if self.previous: + same_lat = self.previous.media_obj.lat == self.media_obj.lat + same_lon = self.previous.media_obj.lon == self.media_obj.lon + if same_lat and same_lon: # We have moved + logger.info("Yes - We're in the same place!") + updatable = True + else: + logger.info("No - We've moved, start a new scrobble") + updatable = False return updatable @property @@ -640,6 +657,8 @@ class Scrobble(TimeStampedModel): media_obj = self.video_game if self.board_game: media_obj = self.board_game + if self.geo_location: + media_obj = self.geo_location return media_obj def __str__(self): @@ -648,7 +667,7 @@ class Scrobble(TimeStampedModel): @classmethod def create_or_update( - cls, media, user_id: int, scrobble_data: dict + cls, media, user_id: int, scrobble_data: dict, **kwargs ) -> "Scrobble": media_class = media.__class__.__name__ @@ -674,6 +693,9 @@ class Scrobble(TimeStampedModel): if media_class == "BoardGame": media_query = models.Q(board_game=media) scrobble_data["board_game_id"] = media.id + if media_class == "GeoLocation": + media_query = models.Q(geo_location=media) + scrobble_data["geo_location_id"] = media.id scrobble = ( cls.objects.filter( @@ -683,6 +705,7 @@ class Scrobble(TimeStampedModel): .order_by("-modified") .first() ) + if scrobble and scrobble.can_be_updated: source = scrobble_data["source"] mtype = media.__class__.__name__ diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index a08adb6..9cc0b1e 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -1,4 +1,5 @@ import logging +import pendulum from typing import Optional from boardgames.bgg import lookup_boardgame_from_bgg @@ -22,6 +23,7 @@ from sports.thesportsdb import lookup_event_from_thesportsdb from videogames.howlongtobeat import lookup_game_from_hltb from videogames.models import VideoGame from videos.models import Video +from locations.models import GeoLocation, RawGeoLocation logger = logging.getLogger(__name__) @@ -242,3 +244,40 @@ def manual_scrobble_board_game(bggeek_id: str, user_id: int): } return Scrobble.create_or_update(boardgame, user_id, scrobble_dict) + + +def gpslogger_scrobble_location( + data_dict: dict, user_id: Optional[int] +) -> Optional[Scrobble]: + # Save the data coming in + if not user_id: + user_id = 1 # TODO fix authing the end point to get user + raw_location = RawGeoLocation.objects.create( + user_id=user_id, + lat=data_dict.get("lat"), + lon=data_dict.get("lon"), + altitude=data_dict.get("alt"), + speed=data_dict.get("spd"), + timestamp=pendulum.parse(data_dict.get("time", timezone.now())), + ) + + location = GeoLocation.find_or_create(data_dict) + + # Now we run off a scrobble + playback_seconds = 1 + extra_data = { + "user_id": user_id, + "timestamp": pendulum.parse(data_dict.get("time", timezone.now())), + "playback_position_seconds": playback_seconds, + "source": "GPSLogger", + } + + scrobble = Scrobble.create_or_update(location, user_id, extra_data) + provider = f"gps source - {data_dict.get('prov')}" + if scrobble.notes: + scrobble.notes = scrobble.notes + f"\n{provider}" + else: + scrobble.notes = provider + scrobble.save(update_fields=["notes"]) + + return scrobble diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index 1cd5485..7b1f800 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -34,6 +34,11 @@ urlpatterns = [ views.lastfm_import, name="lastfm-import", ), + path( + "webhook/gps/", + views.gps_webhook, + name="gps-webhook", + ), path( "webhook/jellyfin/", views.jellyfin_webhook, diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 2515e2c..38f5b95 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -45,6 +45,7 @@ from scrobbles.models import ( Scrobble, ) from scrobbles.scrobblers import ( + gpslogger_scrobble_location, jellyfin_scrobble_track, jellyfin_scrobble_video, manual_scrobble_board_game, @@ -366,6 +367,28 @@ def mopidy_webhook(request): return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK) +@csrf_exempt +@permission_classes([IsAuthenticated]) +@api_view(["POST"]) +def gps_webhook(request): + try: + data_dict = json.loads(request.data) + except TypeError: + data_dict = request.data + + # For making things easier to build new input processors + if getattr(settings, "DUMP_REQUEST_DATA", False): + json_data = json.dumps(data_dict, indent=4) + logger.debug(f"{json_data}") + + scrobble = gpslogger_scrobble_location(data_dict, request.user.id) + + if not scrobble: + return Response({}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK) + + @csrf_exempt @permission_classes([IsAuthenticated]) @api_view(["POST"]) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 5baea47..ee8d05b 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -114,6 +114,7 @@ INSTALLED_APPS = [ "books", "boardgames", "videogames", + "locations", "mathfilters", "rest_framework", "allauth",