Parcourir la source

Add basic location tracking

Colin Powell il y a 1 an
Parent
commit
fc64dfadba

+ 0 - 0
vrobbler/apps/locations/__init__.py


+ 35 - 0
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",
+    )

+ 77 - 0
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")},
+            },
+        ),
+    ]

+ 58 - 0
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,
+            },
+        ),
+    ]

+ 18 - 0
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),
+        ),
+    ]

+ 0 - 0
vrobbler/apps/locations/migrations/__init__.py


+ 88 - 0
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)

+ 1 - 0
vrobbler/apps/locations/views.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 43 - 0
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,
+            ),
+        ),
+    ]

+ 24 - 1
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__

+ 39 - 0
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

+ 5 - 0
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,

+ 23 - 0
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"])

+ 1 - 0
vrobbler/settings.py

@@ -114,6 +114,7 @@ INSTALLED_APPS = [
     "books",
     "boardgames",
     "videogames",
+    "locations",
     "mathfilters",
     "rest_framework",
     "allauth",