Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c001248d1b | |||
| f1c777d4ef | |||
| 931488c288 | |||
| ab897fd848 |
23
PROJECT.org
23
PROJECT.org
@ -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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "56.2"
|
||||
version = "56.4"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user