This commit has no recorded session.
diff
commit f1c777d4efadb2f2bf58b9c21a75a63f9f113812Author: Colin Powell <colin@unbl.ink>Date: Sun Jun 21 01:02:43 2026 -0400 [discgolf] Add trail maps and addresses@@ -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:@@ -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 = [@@ -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__"@@ -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),+ ),+ ]@@ -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})@@ -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 context@@ -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)@@ -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),+ ),+ ]@@ -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)),@@ -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" @@ -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:@@ -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,+ )@@ -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: '© <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 %}