diff --git a/PROJECT.org b/PROJECT.org index 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.py index 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.py index 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.py new file mode 100644 index 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.py index 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.py index 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 context diff --git a/vrobbler/apps/locations/admin.py b/vrobbler/apps/locations/admin.py index 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.py new file mode 100644 index 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.py index 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.py index 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.py index 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.py index 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.html index 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 %} + + +{% endblock %} + {% block lists %} {% if object.description %}
@@ -11,6 +20,30 @@
{% endif %} +{% if trail_gpx_url %} +
+
+
+
+
+{% endif %} +
+
+ {% if object.trail.trailhead_location.display_address %} +

{{ object.trail.trailhead_location.display_address }}

+ {% endif %} + {% if object.pdga_link or object.udisc_link %} +

+ {% if object.pdga_link %} + PDGA + {% endif %} + {% if object.udisc_link %} + uDisc + {% endif %} +

+ {% endif %} +
+
{% if charts %}
@@ -42,3 +75,27 @@
{% endblock %} + +{% block extra_js %} +{{ block.super }} +{% if trail_gpx_url %} + + + +{% endif %} +{% endblock %}