Add basic location tracking

This commit is contained in:
2023-11-23 00:58:11 +01:00
parent a5d729d26a
commit fc64dfadba
14 changed files with 412 additions and 1 deletions

View File

View File

@ -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",
)

View File

@ -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")},
},
),
]

View File

@ -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,
},
),
]

View File

@ -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),
),
]

View File

@ -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)

View File

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

View File

@ -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,
),
),
]

View File

@ -34,6 +34,7 @@ from sports.models import SportEvent
from videogames import retroarch from videogames import retroarch
from videogames.models import VideoGame from videogames.models import VideoGame
from videos.models import Series, Video from videos.models import Series, Video
from locations.models import GeoLocation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -469,6 +470,7 @@ class Scrobble(TimeStampedModel):
BOOK = "Book", "Book" BOOK = "Book", "Book"
VIDEO_GAME = "VideoGame", "Video game" VIDEO_GAME = "VideoGame", "Video game"
BOARD_GAME = "BoardGame", "Board game" BOARD_GAME = "BoardGame", "Board game"
GEO_LOCATION = "GeoLocation", "GeoLocation"
uuid = models.UUIDField(editable=False, **BNULL) uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
@ -486,6 +488,9 @@ class Scrobble(TimeStampedModel):
board_game = models.ForeignKey( board_game = models.ForeignKey(
BoardGame, on_delete=models.DO_NOTHING, **BNULL BoardGame, on_delete=models.DO_NOTHING, **BNULL
) )
geo_location = models.ForeignKey(
GeoLocation, on_delete=models.DO_NOTHING, **BNULL
)
media_type = models.CharField( media_type = models.CharField(
max_length=14, choices=MediaType.choices, default=MediaType.VIDEO max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
) )
@ -612,6 +617,7 @@ class Scrobble(TimeStampedModel):
@property @property
def can_be_updated(self) -> bool: def can_be_updated(self) -> bool:
updatable = True updatable = True
if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values(): if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values():
logger.info(f"No - Long play media") logger.info(f"No - Long play media")
updatable = False updatable = False
@ -621,6 +627,17 @@ class Scrobble(TimeStampedModel):
if self.is_stale: if self.is_stale:
logger.info(f"No - stale - {self.id} - {self.source}") logger.info(f"No - stale - {self.id} - {self.source}")
updatable = False 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 return updatable
@property @property
@ -640,6 +657,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.video_game media_obj = self.video_game
if self.board_game: if self.board_game:
media_obj = self.board_game media_obj = self.board_game
if self.geo_location:
media_obj = self.geo_location
return media_obj return media_obj
def __str__(self): def __str__(self):
@ -648,7 +667,7 @@ class Scrobble(TimeStampedModel):
@classmethod @classmethod
def create_or_update( def create_or_update(
cls, media, user_id: int, scrobble_data: dict cls, media, user_id: int, scrobble_data: dict, **kwargs
) -> "Scrobble": ) -> "Scrobble":
media_class = media.__class__.__name__ media_class = media.__class__.__name__
@ -674,6 +693,9 @@ class Scrobble(TimeStampedModel):
if media_class == "BoardGame": if media_class == "BoardGame":
media_query = models.Q(board_game=media) media_query = models.Q(board_game=media)
scrobble_data["board_game_id"] = media.id 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 = ( scrobble = (
cls.objects.filter( cls.objects.filter(
@ -683,6 +705,7 @@ class Scrobble(TimeStampedModel):
.order_by("-modified") .order_by("-modified")
.first() .first()
) )
if scrobble and scrobble.can_be_updated: if scrobble and scrobble.can_be_updated:
source = scrobble_data["source"] source = scrobble_data["source"]
mtype = media.__class__.__name__ mtype = media.__class__.__name__

View File

@ -1,4 +1,5 @@
import logging import logging
import pendulum
from typing import Optional from typing import Optional
from boardgames.bgg import lookup_boardgame_from_bgg 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.howlongtobeat import lookup_game_from_hltb
from videogames.models import VideoGame from videogames.models import VideoGame
from videos.models import Video from videos.models import Video
from locations.models import GeoLocation, RawGeoLocation
logger = logging.getLogger(__name__) 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) 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

View File

@ -34,6 +34,11 @@ urlpatterns = [
views.lastfm_import, views.lastfm_import,
name="lastfm-import", name="lastfm-import",
), ),
path(
"webhook/gps/",
views.gps_webhook,
name="gps-webhook",
),
path( path(
"webhook/jellyfin/", "webhook/jellyfin/",
views.jellyfin_webhook, views.jellyfin_webhook,

View File

@ -45,6 +45,7 @@ from scrobbles.models import (
Scrobble, Scrobble,
) )
from scrobbles.scrobblers import ( from scrobbles.scrobblers import (
gpslogger_scrobble_location,
jellyfin_scrobble_track, jellyfin_scrobble_track,
jellyfin_scrobble_video, jellyfin_scrobble_video,
manual_scrobble_board_game, manual_scrobble_board_game,
@ -366,6 +367,28 @@ def mopidy_webhook(request):
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK) 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 @csrf_exempt
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@api_view(["POST"]) @api_view(["POST"])

View File

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