Compare commits

...

4 Commits
56.2 ... 56.4

Author SHA1 Message Date
c001248d1b [release] Bump to version 56.4
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Successful in 49s
- Add ability to do reverse address lookup on lat-long pairs
- Add address fields to GeoLocation
- Add better detail template for Disc Golf Courses
2026-06-21 01:04:57 -04:00
f1c777d4ef [discgolf] Add trail maps and addresses
Some checks failed
build / test (push) Has been cancelled
2026-06-21 01:02:43 -04:00
931488c288 [release] Bump to version 56.3
Some checks failed
build / test (push) Has been cancelled
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 32s
- Fix bug in importer script from discgolf being added
2026-06-20 01:12:33 -04:00
ab897fd848 [importers] Just fix a smol bug 2026-06-20 01:12:14 -04:00
15 changed files with 329 additions and 3 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/22] :vrobbler:project:personal:
* Backlog [0/23] :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,27 @@ 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:
* Version 56.4 [3/3]
** 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:
:ID: c3733f96-18f1-eef8-f5d9-edaf97e35623
:END:
* Version 56.2 [1/1]
** DONE [#A] Fix bug in creating people when importing course plays :discgolf:bug:
:PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "56.2"
version = "56.4"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -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 = [

View File

@ -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__"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -93,6 +93,7 @@ def import_from_webdav_for_all_users(
bgstats_count,
ebird_count,
scale_count,
udisc_count,
extra={
"koreader": ko_count,
"trail_gpx": gpx_count,

View File

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

View File

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

View File

@ -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 %}