sessions / f1c777d4efadb2f2bf58b9c21a75a63f9f113812

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit f1c777d4efadb2f2bf58b9c21a75a63f9f113812Author: Colin Powell <colin@unbl.ink>Date:   Sun Jun 21 01:02:43 2026 -0400    [discgolf] Add trail maps and addressesdiff --git a/PROJECT.org b/PROJECT.orgindex b4229c8..00386ac 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/22] :vrobbler:project:personal:+* Backlog [3/26] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: :ID:       702462cf-d54b-48c6-8a7c-78b8de751deb@@ -604,6 +604,19 @@ independent of the email flow it was originally creatdd for  ** TODO [#B] Is there way to create unique slugs for media instances :media_types: +** TODO [#C] What would it look like to add an MCP server to expose scrobbles and media items? :mcpserver:feature:+** DONE [#B] Add ability to do reverse address lookup on lat-long pairs :geolocations:feature:+:PROPERTIES:+:ID:       86c071ff-7638-41ba-6b65-1382df1cb5aa+:END:+** DONE [#B] Add address fields to GeoLocation :addresses:geolocation:+:PROPERTIES:+:ID:       a55ae508-07ab-ccdd-e453-846bd3fca6fb+:END:+** DONE [#B] Add better detail template for Disc Golf Courses :discgolf:templates:+:PROPERTIES:+:ID:       12cee67c-f723-0fa3-848a-cbc6e4d65fc3+:END: * Version 56.3 [1/1] ** DONE [#A] Fix bug in importer script from discgolf being added :bug: :PROPERTIES:diff --git a/vrobbler/apps/discgolf/admin.py b/vrobbler/apps/discgolf/admin.pyindex 3291aae..5d08e2c 100644--- a/vrobbler/apps/discgolf/admin.py+++ b/vrobbler/apps/discgolf/admin.py@@ -6,7 +6,7 @@ from scrobbles.admin import ScrobbleInline @admin.register(DiscGolfCourse) class DiscGolfCourseAdmin(admin.ModelAdmin):     date_hierarchy = "created"-    list_display = ("title", "layout_name", "number_of_holes", "par_total")+    list_display = ("title", "layout_name", "number_of_holes", "par_total", "pdga_slug", "udisc_id")     raw_id_fields = ("trail",)     search_fields = ("title", "layout_name")     inlines = [diff --git a/vrobbler/apps/discgolf/api/serializers.py b/vrobbler/apps/discgolf/api/serializers.pyindex b11c9f2..c41a071 100644--- a/vrobbler/apps/discgolf/api/serializers.py+++ b/vrobbler/apps/discgolf/api/serializers.py@@ -3,6 +3,9 @@ from rest_framework import serializers   class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):+    pdga_link = serializers.ReadOnlyField()+    udisc_link = serializers.ReadOnlyField()+     class Meta:         model = models.DiscGolfCourse         fields = "__all__"diff --git a/vrobbler/apps/discgolf/migrations/0004_discgolfcourse_pdga_slug_discgolfcourse_udisc_id.py b/vrobbler/apps/discgolf/migrations/0004_discgolfcourse_pdga_slug_discgolfcourse_udisc_id.pynew file mode 100644index 0000000..2eef633--- /dev/null+++ b/vrobbler/apps/discgolf/migrations/0004_discgolfcourse_pdga_slug_discgolfcourse_udisc_id.py@@ -0,0 +1,23 @@+# Generated by Django 4.2.29 on 2026-06-21 04:16++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("discgolf", "0003_discgolfcourse_par_per_hole"),+    ]++    operations = [+        migrations.AddField(+            model_name="discgolfcourse",+            name="pdga_slug",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+        migrations.AddField(+            model_name="discgolfcourse",+            name="udisc_id",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+    ]diff --git a/vrobbler/apps/discgolf/models.py b/vrobbler/apps/discgolf/models.pyindex 39b17f2..9255398 100644--- a/vrobbler/apps/discgolf/models.py+++ b/vrobbler/apps/discgolf/models.py@@ -39,6 +39,20 @@ class DiscGolfCourse(ScrobblableMixin):     trail = models.ForeignKey(         "trails.Trail", on_delete=models.DO_NOTHING, **BNULL     )+    pdga_slug = models.CharField(max_length=255, **BNULL)+    udisc_id = models.CharField(max_length=255, **BNULL)++    @property+    def pdga_link(self) -> str:+        if self.pdga_slug:+            return f"https://www.pdga.com/course-directory/course/{self.pdga_slug}/"+        return ""++    @property+    def udisc_link(self) -> str:+        if self.udisc_id:+            return f"https://udisc.com/courses/{self.udisc_id}/"+        return ""      def get_absolute_url(self) -> str:         return reverse("discgolf:course_detail", kwargs={"slug": self.uuid})diff --git a/vrobbler/apps/discgolf/views.py b/vrobbler/apps/discgolf/views.pyindex 9498d24..e4f5593 100644--- a/vrobbler/apps/discgolf/views.py+++ b/vrobbler/apps/discgolf/views.py@@ -1,3 +1,5 @@+from django.apps import apps+ from discgolf.models import DiscGolfCourse  from scrobbles.views import (@@ -13,3 +15,18 @@ class DiscGolfCourseListView(ScrobbleableListView):  class DiscGolfCourseDetailView(ScrobbleableDetailView, ChartContextMixin):     model = DiscGolfCourse++    def get_context_data(self, **kwargs):+        context = super().get_context_data(**kwargs)+        Scrobble = apps.get_model("scrobbles", "Scrobble")+        context["trail_gpx_url"] = None+        latest = (+            Scrobble.objects.filter(+                trail=self.object.trail, gpx_file__isnull=False+            )+            .order_by("-timestamp")+            .first()+        )+        if latest and latest.gpx_file:+            context["trail_gpx_url"] = latest.gpx_file.url+        return contextdiff --git a/vrobbler/apps/locations/admin.py b/vrobbler/apps/locations/admin.pyindex 910ca21..51cdbbc 100644--- a/vrobbler/apps/locations/admin.py+++ b/vrobbler/apps/locations/admin.py@@ -1,4 +1,7 @@+import time+ from django.contrib import admin+from django.http import HttpRequest  from locations.models import GeoLocation @@ -14,9 +17,29 @@ class GeoLocationAdmin(admin.ModelAdmin):         "lon",         "title",         "altitude",+        "city",+        "state_province",+        "country",     )     ordering = ("-created",)     search_fields = ("title",)+    actions = ["reverse_geocode_selected"]     inlines = [         ScrobbleInline,     ]++    @admin.action(description="Reverse geocode selected locations")+    def reverse_geocode_selected(self, request: HttpRequest, queryset):+        updated = 0+        errors = 0+        for i, location in enumerate(queryset.iterator()):+            if location.reverse_geocode():+                updated += 1+            else:+                errors += 1+            if i < queryset.count() - 1:+                time.sleep(1.1)+        msg = f"Reverse geocoded {updated} locations"+        if errors:+            msg += f", {errors} failed"+        self.message_user(request, msg)diff --git a/vrobbler/apps/locations/migrations/0011_geolocation_city_geolocation_country_and_more.py b/vrobbler/apps/locations/migrations/0011_geolocation_city_geolocation_country_and_more.pynew file mode 100644index 0000000..4e88bc3--- /dev/null+++ b/vrobbler/apps/locations/migrations/0011_geolocation_city_geolocation_country_and_more.py@@ -0,0 +1,38 @@+# Generated by Django 4.2.29 on 2026-06-21 04:26++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("locations", "0010_clean_start"),+    ]++    operations = [+        migrations.AddField(+            model_name="geolocation",+            name="city",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+        migrations.AddField(+            model_name="geolocation",+            name="country",+            field=models.CharField(blank=True, max_length=100, null=True),+        ),+        migrations.AddField(+            model_name="geolocation",+            name="postal_code",+            field=models.CharField(blank=True, max_length=20, null=True),+        ),+        migrations.AddField(+            model_name="geolocation",+            name="state_province",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+        migrations.AddField(+            model_name="geolocation",+            name="street",+            field=models.TextField(blank=True, null=True),+        ),+    ]diff --git a/vrobbler/apps/locations/models.py b/vrobbler/apps/locations/models.pyindex 75dc496..0dc1ee3 100644--- a/vrobbler/apps/locations/models.py+++ b/vrobbler/apps/locations/models.py@@ -45,6 +45,11 @@ class GeoLocation(ScrobblableMixin):     truncated_lat = models.FloatField(**BNULL)     truncated_lon = models.FloatField(**BNULL)     altitude = models.FloatField(**BNULL)+    street = models.TextField(**BNULL)+    city = models.CharField(max_length=255, **BNULL)+    state_province = models.CharField(max_length=255, **BNULL)+    postal_code = models.CharField(max_length=20, **BNULL)+    country = models.CharField(max_length=100, **BNULL)      class Meta:         unique_together = [["lat", "lon", "altitude"]]@@ -55,6 +60,11 @@ class GeoLocation(ScrobblableMixin):          return f"{self.lat} x {self.lon}" +    @property+    def display_address(self) -> str:+        parts = [self.street, self.city, self.state_province, self.postal_code, self.country]+        return ", ".join(p for p in parts if p)+     def get_absolute_url(self):         return reverse("locations:geolocation_detail", kwargs={"slug": self.uuid}) @@ -121,6 +131,17 @@ class GeoLocation(ScrobblableMixin):          return fetch_current_weather(self.lat, self.lon) +    def reverse_geocode(self) -> bool:+        from locations.utils import reverse_geocode++        result = reverse_geocode(self.lat, self.lon)+        if result is None:+            return False+        for field, value in result.items():+            setattr(self, field, value)+        self.save(update_fields=list(result.keys()))+        return True+     def loc_diff(self, old_lat_lon: tuple) -> tuple:         return (             abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),diff --git a/vrobbler/apps/locations/utils.py b/vrobbler/apps/locations/utils.pyindex ada16c6..a144457 100644--- a/vrobbler/apps/locations/utils.py+++ b/vrobbler/apps/locations/utils.py@@ -201,6 +201,50 @@ def detect_movement(     return result  +NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"++USER_AGENT = "Vrobbler/1.0 (https://github.com/secstate/vrobbler)"+++def reverse_geocode(lat: float, lon: float) -> Optional[dict]:+    """Reverse geocode lat/lon to an address using Nominatim.++    Returns a dict with address fields, or None on failure.+    Nominatim usage policy: max 1 request per second.+    """+    params = {+        "lat": lat,+        "lon": lon,+        "format": "json",+    }+    headers = {"User-Agent": USER_AGENT}+    try:+        resp = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=10)+        resp.raise_for_status()+    except requests.RequestException as e:+        logger.warning("Failed to reverse geocode %s,%s: %s", lat, lon, e)+        return None++    data = resp.json()+    if "error" in data:+        logger.warning("Nominatim error for %s,%s: %s", lat, lon, data["error"])+        return None++    address = data.get("address", {})+    return {+        "street": address.get("road")+        or address.get("pedestrian")+        or address.get("footway"),+        "city": address.get("city")+        or address.get("town")+        or address.get("village")+        or address.get("hamlet"),+        "state_province": address.get("state"),+        "postal_code": address.get("postcode"),+        "country": address.get("country"),+    }++ NWS_URL = "https://forecast.weather.gov/MapClick.php"  diff --git a/vrobbler/apps/scrobbles/signals.py b/vrobbler/apps/scrobbles/signals.pyindex 0593c22..5971c70 100644--- a/vrobbler/apps/scrobbles/signals.py+++ b/vrobbler/apps/scrobbles/signals.py@@ -10,6 +10,7 @@ from scrobbles.tasks import (     add_favorite_to_mopidy_playlist,     CHARTABLE_MEDIA_TYPES,     remove_favorite_from_mopidy_playlist,+    reverse_geocode_geolocation,     SCROBBLES_WITHOUT_CHARTS,     update_charts_for_timestamp, )@@ -86,6 +87,31 @@ def add_tags_from_task_title(sender, instance, **kwargs):             instance.tags.add(tag)  +@receiver(post_save, sender=Scrobble)+def reverse_geocode_on_scrobble_creation(sender, instance, created, **kwargs):+    if not created:+        return+    if not instance.geo_location_id:+        logger.info(+            "Skipping reverse geocode: scrobble %s has no geo_location",+            instance.id,+        )+        return+    if instance.geo_location.postal_code:+        logger.info(+            "Skipping reverse geocode: geo_location %s already has postal_code %s",+            instance.geo_location_id,+            instance.geo_location.postal_code,+        )+        return++    logger.info(+        "Enqueuing reverse geocode for geo_location %s",+        instance.geo_location_id,+    )+    reverse_geocode_geolocation.delay(instance.geo_location_id)++ @receiver(post_save, sender=FavoriteMedia) def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):     if not created:diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.pyindex 0fcf512..6736050 100644--- a/vrobbler/apps/scrobbles/tasks.py+++ b/vrobbler/apps/scrobbles/tasks.py@@ -726,3 +726,41 @@ def add_scrobble_to_mopidy_monthly_playlist(scrobble_id):                 break      add_track_to_mopidy_monthly_playlist(scrobble)+++@shared_task+def reverse_geocode_geolocation(geo_location_id):+    from locations.models import GeoLocation++    location = GeoLocation.objects.filter(id=geo_location_id).first()+    if not location:+        logger.info(+            "Skipping reverse geocode: geo_location %s not found",+            geo_location_id,+        )+        return+    if location.postal_code:+        logger.info(+            "Skipping reverse geocode: geo_location %s already has postal_code %s",+            geo_location_id,+            location.postal_code,+        )+        return++    logger.info(+        "Reverse geocoding geo_location %s (%s, %s)",+        geo_location_id,+        location.lat,+        location.lon,+    )+    if location.reverse_geocode():+        logger.info(+            "Reverse geocode succeeded for geo_location %s: %s",+            geo_location_id,+            location.display_address,+        )+    else:+        logger.warning(+            "Reverse geocode failed for geo_location %s",+            geo_location_id,+        )diff --git a/vrobbler/templates/discgolf/discgolfcourse_detail.html b/vrobbler/templates/discgolf/discgolfcourse_detail.htmlindex d087772..d269261 100644--- a/vrobbler/templates/discgolf/discgolfcourse_detail.html+++ b/vrobbler/templates/discgolf/discgolfcourse_detail.html@@ -3,6 +3,15 @@  {% block title %}{{object.title}}{% endblock %} +{% block head_extra %}+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />+<style>+    #map {+        height: 400px;+    }+</style>+{% endblock %}+ {% block lists %} {% if object.description %} <div class="row">@@ -11,6 +20,30 @@     </div> </div> {% endif %}+{% if trail_gpx_url %}+<div class="row">+    <div class="col-md mb-3">+        <div id="map"></div>+    </div>+</div>+{% endif %}+<div class="row">+    <div class="col-md">+        {% if object.trail.trailhead_location.display_address %}+        <p>{{ object.trail.trailhead_location.display_address }}</p>+        {% endif %}+        {% if object.pdga_link or object.udisc_link %}+        <p>+            {% if object.pdga_link %}+            <a href="{{ object.pdga_link }}" target="_blank">PDGA</a>+            {% endif %}+            {% if object.udisc_link %}+            <a href="{{ object.udisc_link }}" target="_blank">uDisc</a>+            {% endif %}+        </p>+        {% endif %}+    </div>+</div> {% if charts %} <div class="row">     <div class="col-md">@@ -42,3 +75,27 @@     </div> </div> {% endblock %}++{% block extra_js %}+{{ block.super }}+{% if trail_gpx_url %}+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>+<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>+<script>+var map = L.map('map');+L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {+    maxZoom: 19,+    attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',+    referrerPolicy: 'origin'+}).addTo(map);+var gpx = new L.GPX("{{ trail_gpx_url|escapejs }}", {+    async: true,+    polyline_options: { color: '#e74c3c' }+});+gpx.on('loaded', function(e) {+    map.fitBounds(e.target.getBounds());+});+gpx.addTo(map);+</script>+{% endif %}+{% endblock %}