sessions / c3673e267985077f1068a3d91f327837668fe92d

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

author
Colin Powell <colin@unbl.ink>
recorded
2026-07-16 23:43:21 EDT
agent
opencode
message
[nature] Add iNaturalist observation importer and scrobbles - New nature app with SpeciesObservation model (scrobblable) - SpeciesObservationLogData for observation-specific fields - Atom feed parser + iNat REST API enrichment - Photo download and local storage - Celery periodic import (every 4 hours) - Management command: import_from_inaturalist - Species fields: title, scientific_name, taxon_id, iconic_taxon_name, rank, wikipedia_url - Log fields: observation_id, photo_urls, quality_grade, place_guess, lat/lon, uri, identifications_count - Updates existing scrobbles when observation data changes - iNaturalist user mapping on UserProfile - Templates, admin, API, URLs - Default 5 minute run time for species observations

prompt

Build a nature app that imports iNaturalist observations as scrobbles, using Atom feed for discovery and iNat REST API for enrichment, with photo download and periodic Celery import

diff

ignore whitespace

commit c3673e267985077f1068a3d91f327837668fe92dAuthor: Colin Powell <colin@unbl.ink>Date:   Thu Jul 16 23:43:21 2026 -0400    [nature] Add iNaturalist observation importer and scrobbles        - New nature app with SpeciesObservation model (scrobblable)    - SpeciesObservationLogData for observation-specific fields    - Atom feed parser + iNat REST API enrichment    - Photo download and local storage    - Celery periodic import (every 4 hours)    - Management command: import_from_inaturalist    - Species fields: title, scientific_name, taxon_id, iconic_taxon_name, rank, wikipedia_url    - Log fields: observation_id, photo_urls, quality_grade, place_guess, lat/lon, uri, identifications_count    - Updates existing scrobbles when observation data changes    - iNaturalist user mapping on UserProfile    - Templates, admin, API, URLs    - Default 5 minute run time for species observationsNotes (sessions):    {      "version": 1,      "commit_sha": "c3673e267985077f1068a3d91f327837668fe92d",      "message": "[nature] Add iNaturalist observation importer and scrobbles\n\n- New nature app with SpeciesObservation model (scrobblable)\n- SpeciesObservationLogData for observation-specific fields\n- Atom feed parser + iNat REST API enrichment\n- Photo download and local storage\n- Celery periodic import (every 4 hours)\n- Management command: import_from_inaturalist\n- Species fields: title, scientific_name, taxon_id, iconic_taxon_name, rank, wikipedia_url\n- Log fields: observation_id, photo_urls, quality_grade, place_guess, lat/lon, uri, identifications_count\n- Updates existing scrobbles when observation data changes\n- iNaturalist user mapping on UserProfile\n- Templates, admin, API, URLs\n- Default 5 minute run time for species observations",      "prompt": "Build a nature app that imports iNaturalist observations as scrobbles, using Atom feed for discovery and iNat REST API for enrichment, with photo download and periodic Celery import",      "agent": "opencode",      "author": "Colin Powell \u003ccolin@unbl.ink\u003e",      "timestamp": "2026-07-16T23:43:21.895744356-04:00",      "diff_stat": "PROJECT.org                                        |  17 +-\n vrobbler/apps/nature/__init__.py                   |   0\n vrobbler/apps/nature/admin.py                      |  14 +\n vrobbler/apps/nature/api/__init__.py               |   0\n vrobbler/apps/nature/api/serializers.py            |   8 +\n vrobbler/apps/nature/api/views.py                  |   9 +\n vrobbler/apps/nature/apps.py                       |   5 +\n vrobbler/apps/nature/importer.py                   | 312 +++++++++++++++++++++\n vrobbler/apps/nature/management/__init__.py        |   0\n .../apps/nature/management/commands/__init__.py    |   0\n .../management/commands/import_from_inaturalist.py |  10 +\n vrobbler/apps/nature/migrations/0001_initial.py    |  88 ++++++\n ...peciesobservation_iconic_taxon_name_and_more.py |  28 ++\n vrobbler/apps/nature/migrations/__init__.py        |   0\n vrobbler/apps/nature/models.py                     | 101 +++++++\n vrobbler/apps/nature/tasks.py                      |  13 +\n vrobbler/apps/nature/urls.py                       |  17 ++\n vrobbler/apps/nature/views.py                      |  11 +\n vrobbler/apps/profiles/forms.py                    |   3 +\n ...userprofile_inaturalist_auto_import_and_more.py |  28 ++\n vrobbler/apps/profiles/models.py                   |   4 +\n vrobbler/apps/scrobbles/constants.py               |   1 +\n .../0103_scrobble_species_observation_and_more.py  |  94 +++++++\n vrobbler/apps/scrobbles/models.py                  |  17 +-\n vrobbler/settings.py                               |   5 +\n .../nature/species_observation_detail.html         |  52 ++++\n .../templates/nature/species_observation_list.html |  10 +\n vrobbler/urls.py                                   |   4 +\n 28 files changed, 846 insertions(+), 5 deletions(-)"    }diff --git a/PROJECT.org b/PROJECT.orgindex b474e67..05e2e95 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [1/25] :vrobbler:project:personal:+* Backlog [3/26] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES:@@ -596,6 +596,21 @@ The Edit log form should have from top to bottom:   - Expansion ids (which should a multi-select widget of expansions for this game)   - Location (which should be a drop down of BoardGameLocations for this user)          +** TODO [#B] Auto sync board game scrobbles to BGG :boardgames:++*** Description++++** DONE [#B] Try adding a nature app with Observations and Species+:PROPERTIES:+:ID:       9551cf06-02b7-1bc9-3600-b0407ecc5967+:END:++*** Description++iNaturalist has a feed in atom.+ ** DONE [#A] Add ability to push one or sync all board game scrobbles to BGG :boardgames: :PROPERTIES: :ID:       1f306552-35e8-4a75-a661-b0956e8de967diff --git a/vrobbler/apps/nature/__init__.py b/vrobbler/apps/nature/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/admin.py b/vrobbler/apps/nature/admin.pynew file mode 100644index 0000000..2f2b854--- /dev/null+++ b/vrobbler/apps/nature/admin.py@@ -0,0 +1,14 @@+from django.contrib import admin+from nature.models import SpeciesObservation+from scrobbles.admin import ScrobbleInline+++@admin.register(SpeciesObservation)+class SpeciesObservationAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    list_display = ("uuid", "title", "scientific_name", "taxon_id")+    ordering = ("-created",)+    search_fields = ("title", "scientific_name")+    inlines = [+        ScrobbleInline,+    ]diff --git a/vrobbler/apps/nature/api/__init__.py b/vrobbler/apps/nature/api/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/api/serializers.py b/vrobbler/apps/nature/api/serializers.pynew file mode 100644index 0000000..bd533c1--- /dev/null+++ b/vrobbler/apps/nature/api/serializers.py@@ -0,0 +1,8 @@+from nature.models import SpeciesObservation+from rest_framework import serializers+++class SpeciesObservationSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = SpeciesObservation+        fields = "__all__"diff --git a/vrobbler/apps/nature/api/views.py b/vrobbler/apps/nature/api/views.pynew file mode 100644index 0000000..a209d93--- /dev/null+++ b/vrobbler/apps/nature/api/views.py@@ -0,0 +1,9 @@+from nature import models+from nature.api import serializers+from rest_framework import permissions, viewsets+++class SpeciesObservationViewSet(viewsets.ModelViewSet):+    queryset = models.SpeciesObservation.objects.all().order_by("-created")+    serializer_class = serializers.SpeciesObservationSerializer+    permission_classes = [permissions.IsAuthenticated]diff --git a/vrobbler/apps/nature/apps.py b/vrobbler/apps/nature/apps.pynew file mode 100644index 0000000..974fbd9--- /dev/null+++ b/vrobbler/apps/nature/apps.py@@ -0,0 +1,5 @@+from django.apps import AppConfig+++class NatureConfig(AppConfig):+    name = "nature"diff --git a/vrobbler/apps/nature/importer.py b/vrobbler/apps/nature/importer.pynew file mode 100644index 0000000..16ed438--- /dev/null+++ b/vrobbler/apps/nature/importer.py@@ -0,0 +1,312 @@+import logging+import re+import xml.etree.ElementTree as ET+from datetime import timedelta++import requests+from django.contrib.auth import get_user_model+from django.core.files.base import ContentFile+from django.utils.dateparse import parse_datetime+from nature.models import SpeciesObservation, SpeciesObservationLogData+from scrobbles.models import Scrobble++logger = logging.getLogger(__name__)++User = get_user_model()++INATURALIST_ATOM_URL = "https://www.inaturalist.org/observations.atom"+INATURALIST_API_URL = "https://api.inaturalist.org/v1/observations"+ATOM_NS = "http://www.w3.org/2005/Atom"+GEORSS_NS = "http://www.georss.org/georss"+++def fetch_atom_feed(username: str) -> str:+    """Fetch the Atom feed for a given iNaturalist username."""+    url = (+        f"{INATURALIST_ATOM_URL}?verifiable=any&page=1&spam=&place_id=any"+        f"&user_id={username}&project_id="+    )+    response = requests.get(url, timeout=30)+    response.raise_for_status()+    return response.text+++def parse_atom_entries(xml_text: str) -> list[dict]:+    """Parse Atom XML and extract observation entries."""+    root = ET.fromstring(xml_text)+    entries = []++    for entry in root.findall(f"{{{ATOM_NS}}}entry"):+        entry_id = entry.findtext(f"{{{ATOM_NS}}}id", "")+        observation_id = entry_id.split("/")[-1] if "/" in entry_id else entry_id++        title = entry.findtext(f"{{{ATOM_NS}}}title", "")+        published = entry.findtext(f"{{{ATOM_NS}}}published", "")+        updated = entry.findtext(f"{{{ATOM_NS}}}updated", "")++        content_el = entry.find(f"{{{ATOM_NS}}}content")+        photo_urls = []+        if content_el is not None and content_el.text:+            photo_urls = re.findall(r'src="([^"]+)"', content_el.text)++        georss_point = entry.findtext(f"{{{GEORSS_NS}}}point", "")+        georss_feature = entry.findtext(f"{{{GEORSS_NS}}}featureName", "")++        lat, lon = None, None+        if georss_point:+            parts = georss_point.strip().split()+            if len(parts) == 2:+                lat, lon = float(parts[0]), float(parts[1])++        entries.append(+            {+                "observation_id": int(observation_id),+                "title": title,+                "published": published,+                "updated": updated,+                "photo_urls": photo_urls,+                "lat": lat,+                "lon": lon,+                "place_guess": georss_feature,+            }+        )++    return entries+++def fetch_observation_detail(observation_id: int) -> dict:+    """Fetch detailed observation data from the iNaturalist REST API."""+    url = f"{INATURALIST_API_URL}/{observation_id}"+    response = requests.get(url, timeout=30)+    response.raise_for_status()+    data = response.json()+    if data.get("results"):+        return data["results"][0]+    return {}+++def download_photo(url: str) -> tuple[str, bytes] | None:+    """Download a photo and return (filename, content)."""+    try:+        response = requests.get(url, timeout=30)+        response.raise_for_status()+        filename = url.split("/")[-1].split("?")[0]+        if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):+            filename = "observation.jpg"+        return filename, response.content+    except Exception:+        logger.exception(f"Failed to download photo: {url}")+        return None+++def _update_species_photo(species: SpeciesObservation, taxon: dict) -> None:+    """Download and save the default taxon photo for a species, if needed."""+    if species.photo is not None:+        return+    default_photo = taxon.get("default_photo", {})+    photo_url = default_photo.get("url")+    if not photo_url:+        return+    photo_url = photo_url.replace("square", "medium")+    downloaded = download_photo(photo_url)+    if downloaded:+        filename, content = downloaded+        species.photo.save(filename, ContentFile(content), save=True)+++def import_observations_for_user(user) -> list[int]:+    """Import iNaturalist observations for a single user.++    Returns a list of created Scrobble IDs.+    """+    profile = getattr(user, "profile", None)+    if not profile or not profile.inaturalist_username:+        logger.info(f"No iNaturalist username for user {user}, skipping")+        return []++    username = profile.inaturalist_username+    logger.info(f"Importing iNaturalist observations for {username}")++    created_scrobble_ids = []++    try:+        xml_text = fetch_atom_feed(username)+        entries = parse_atom_entries(xml_text)+    except Exception:+        logger.exception(f"Failed to fetch/parse Atom feed for {username}")+        return []++    for entry_data in entries:+        try:+            scrobble_id = _process_entry(user, entry_data)+            if scrobble_id:+                created_scrobble_ids.append(scrobble_id)+        except Exception:+            logger.exception(+                f"Failed to process observation {entry_data['observation_id']}"+            )++    logger.info(f"Imported {len(created_scrobble_ids)} observations for {username}")+    return created_scrobble_ids+++def _process_entry(user, entry_data: dict) -> int | None:+    """Process a single Atom entry into a SpeciesObservation and Scrobble.++    Each iNaturalist observation becomes a new Scrobble.  If a Scrobble+    already exists for this observation, check whether the title or+    species data has changed and update in place.++    Returns the Scrobble ID.+    """+    observation_id = entry_data["observation_id"]+    entry_title = entry_data["title"]++    existing_scrobble = Scrobble.objects.filter(+        species_observation__isnull=False,+        user=user,+        source_id=str(observation_id),+    ).first()++    detail = fetch_observation_detail(observation_id)++    species = None+    taxon = detail.get("taxon", {})+    if taxon:+        common_name = taxon.get("preferred_common_name", entry_title)+        scientific_name = taxon.get("name", "")+        taxon_id = taxon.get("id")+        iconic_taxon_name = taxon.get("iconic_taxon_name", "")+        taxon_rank = taxon.get("rank", "")+        wikipedia_url = taxon.get("wikipedia_url", "") or ""+        if taxon_id and common_name:+            species = SpeciesObservation.find_or_create(+                common_name,+                scientific_name,+                taxon_id,+                iconic_taxon_name=iconic_taxon_name,+                rank=taxon_rank,+                wikipedia_url=wikipedia_url,+            )+            _update_species_photo(species, taxon)+            changed_fields = []+            if species.title != common_name:+                species.title = common_name+                changed_fields.append("title")+            if species.iconic_taxon_name != iconic_taxon_name:+                species.iconic_taxon_name = iconic_taxon_name+                changed_fields.append("iconic_taxon_name")+            if species.rank != taxon_rank:+                species.rank = taxon_rank+                changed_fields.append("rank")+            if species.wikipedia_url != wikipedia_url:+                species.wikipedia_url = wikipedia_url+                changed_fields.append("wikipedia_url")+            if changed_fields:+                species.save(update_fields=changed_fields)++    quality_grade = detail.get("quality_grade", "")+    place_guess = detail.get("place_guess", entry_data.get("place_guess", ""))+    description = detail.get("description", "")++    coords = detail.get("geojson", {}).get("coordinates")+    if coords and len(coords) == 2:+        lon, lat = coords[0], coords[1]+    else:+        lat = entry_data.get("lat")+        lon = entry_data.get("lon")++    observed_at = detail.get("time_observed_at") or detail.get("observed_on")++    photo_url = entry_data["photo_urls"][0] if entry_data["photo_urls"] else None++    all_photo_urls = entry_data.get("photo_urls", [])+    if detail.get("photos"):+        all_photo_urls = [+            p.get("url", "").replace("square", "medium") for p in detail["photos"]+        ]++    uri = detail.get("uri", "")+    identifications_count = detail.get("identifications_count", 0)++    log_data = SpeciesObservationLogData(+        observation_id=observation_id,+        photo_urls=all_photo_urls,+        quality_grade=quality_grade,+        place_guess=place_guess,+        lat=lat,+        lon=lon,+        description=description or "",+        uri=uri or "",+        identifications_count=identifications_count,+    )++    if existing_scrobble:+        changed = False++        if species and existing_scrobble.species_observation_id != species.id:+            existing_scrobble.species_observation = species+            changed = True++        if existing_scrobble.log.get("place_guess") != place_guess:+            changed = True++        if existing_scrobble.log.get("quality_grade") != quality_grade:+            changed = True++        if existing_scrobble.log.get("photo_urls") != all_photo_urls:+            changed = True++        if existing_scrobble.log.get("uri") != uri:+            changed = True++        if existing_scrobble.log.get("identifications_count") != identifications_count:+            changed = True++        if not changed:+            logger.debug(f"Observation {observation_id} unchanged, skipping")+            return existing_scrobble.id++        logger.info(f"Observation {observation_id} updated, refreshing scrobble")+        existing_scrobble.log = log_data.asdict+        existing_scrobble.save(update_fields=["log", "species_observation"])+        return existing_scrobble.id++    timestamp = parse_datetime(observed_at) if observed_at else None+    stop_timestamp = timestamp + timedelta(minutes=5) if timestamp else None++    scrobble = Scrobble.objects.create(+        user=user,+        species_observation=species,+        media_type=Scrobble.MediaType.SPECIES_OBSERVATION,+        source="iNaturalist",+        source_id=str(observation_id),+        timestamp=timestamp,+        stop_timestamp=stop_timestamp,+        played_to_completion=True,+        in_progress=False,+        log=log_data.asdict,+    )++    if photo_url:+        downloaded = download_photo(photo_url)+        if downloaded:+            filename, content = downloaded+            scrobble.screenshot.save(filename, ContentFile(content), save=True)++    return scrobble.id+++def import_observations_for_all_users() -> list[int]:+    """Import iNaturalist observations for all users with auto-import enabled."""+    users = User.objects.filter(+        profile__inaturalist_auto_import=True,+        profile__inaturalist_username__isnull=False,+    ).exclude(profile__inaturalist_username="")++    all_scrobble_ids = []+    for user in users:+        scrobble_ids = import_observations_for_user(user)+        all_scrobble_ids.extend(scrobble_ids)++    return all_scrobble_idsdiff --git a/vrobbler/apps/nature/management/__init__.py b/vrobbler/apps/nature/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/management/commands/__init__.py b/vrobbler/apps/nature/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/management/commands/import_from_inaturalist.py b/vrobbler/apps/nature/management/commands/import_from_inaturalist.pynew file mode 100644index 0000000..d9233b1--- /dev/null+++ b/vrobbler/apps/nature/management/commands/import_from_inaturalist.py@@ -0,0 +1,10 @@+from django.core.management.base import BaseCommand+from nature.importer import import_observations_for_all_users+++class Command(BaseCommand):+    help = "Import iNaturalist observations for all users with auto-import enabled"++    def handle(self, *args, **options):+        count = len(import_observations_for_all_users())+        self.stdout.write(f"Imported {count} observations")diff --git a/vrobbler/apps/nature/migrations/0001_initial.py b/vrobbler/apps/nature/migrations/0001_initial.pynew file mode 100644index 0000000..c97eb42--- /dev/null+++ b/vrobbler/apps/nature/migrations/0001_initial.py@@ -0,0 +1,88 @@+# Generated by Django 4.2.29 on 2026-07-17 02:42++import uuid++import django_extensions.db.fields+import taggit.managers+from django.db import migrations, models+++class Migration(migrations.Migration):++    initial = True++    dependencies = [+        ("scrobbles", "0102_favoritemedia_drink_scrobble_drink_and_more"),+        ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),+    ]++    operations = [+        migrations.CreateModel(+            name="SpeciesObservation",+            fields=[+                (+                    "id",+                    models.BigAutoField(+                        auto_created=True,+                        primary_key=True,+                        serialize=False,+                        verbose_name="ID",+                    ),+                ),+                (+                    "created",+                    django_extensions.db.fields.CreationDateTimeField(+                        auto_now_add=True, verbose_name="created"+                    ),+                ),+                (+                    "modified",+                    django_extensions.db.fields.ModificationDateTimeField(+                        auto_now=True, verbose_name="modified"+                    ),+                ),+                (+                    "uuid",+                    models.UUIDField(+                        blank=True, default=uuid.uuid4, editable=False, null=True+                    ),+                ),+                ("title", models.CharField(blank=True, max_length=255, null=True)),+                ("base_run_time_seconds", models.IntegerField(blank=True, null=True)),+                (+                    "scientific_name",+                    models.CharField(blank=True, max_length=255, null=True),+                ),+                ("taxon_id", models.IntegerField(db_index=True, unique=True)),+                (+                    "photo",+                    models.ImageField(+                        blank=True, null=True, upload_to="nature/species/"+                    ),+                ),+                (+                    "genre",+                    taggit.managers.TaggableManager(+                        blank=True,+                        help_text="A comma-separated list of tags.",+                        through="scrobbles.ObjectWithGenres",+                        to="scrobbles.Genre",+                        verbose_name="Genre",+                    ),+                ),+                (+                    "tags",+                    taggit.managers.TaggableManager(+                        blank=True,+                        help_text="A comma-separated list of tags.",+                        through="taggit.TaggedItem",+                        to="taggit.Tag",+                        verbose_name="Tags",+                    ),+                ),+            ],+            options={+                "abstract": False,+            },+        ),+    ]diff --git a/vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.py b/vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.pynew file mode 100644index 0000000..b44c98d--- /dev/null+++ b/vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.py@@ -0,0 +1,28 @@+# Generated by Django 4.2.29 on 2026-07-17 03:38++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("nature", "0001_initial"),+    ]++    operations = [+        migrations.AddField(+            model_name="speciesobservation",+            name="iconic_taxon_name",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+        migrations.AddField(+            model_name="speciesobservation",+            name="rank",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+        migrations.AddField(+            model_name="speciesobservation",+            name="wikipedia_url",+            field=models.URLField(blank=True, max_length=1024, null=True),+        ),+    ]diff --git a/vrobbler/apps/nature/migrations/__init__.py b/vrobbler/apps/nature/migrations/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/models.py b/vrobbler/apps/nature/models.pynew file mode 100644index 0000000..654808a--- /dev/null+++ b/vrobbler/apps/nature/models.py@@ -0,0 +1,101 @@+import logging+from dataclasses import dataclass+from typing import Optional++from django.db import models+from django.urls import reverse+from imagekit.models import ImageSpecField+from imagekit.processors import ResizeToFit+from scrobbles.dataclasses import BaseLogData, WithPeopleLogData+from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin++logger = logging.getLogger(__name__)+BNULL = {"blank": True, "null": True}+++@dataclass+class SpeciesObservationLogData(BaseLogData, WithPeopleLogData):+    observation_id: Optional[int] = None+    photo_urls: Optional[list[str]] = None+    quality_grade: Optional[str] = None+    place_guess: Optional[str] = None+    lat: Optional[float] = None+    lon: Optional[float] = None+    uri: Optional[str] = None+    identifications_count: Optional[int] = None+++class SpeciesObservation(ScrobblableMixin):+    scientific_name = models.CharField(max_length=255, **BNULL)+    taxon_id = models.IntegerField(unique=True, db_index=True)+    iconic_taxon_name = models.CharField(max_length=255, **BNULL)+    rank = models.CharField(max_length=255, **BNULL)+    wikipedia_url = models.URLField(max_length=1024, **BNULL)+    photo = models.ImageField(upload_to="nature/species/", **BNULL)+    photo_small = ImageSpecField(+        source="photo",+        processors=[ResizeToFit(100, 100)],+        format="JPEG",+        options={"quality": 60},+    )+    photo_medium = ImageSpecField(+        source="photo",+        processors=[ResizeToFit(300, 300)],+        format="JPEG",+        options={"quality": 75},+    )++    def __str__(self):+        if self.scientific_name:+            return f"{self.title} ({self.scientific_name})"+        return self.title or str(self.uuid)++    def get_absolute_url(self):+        return reverse("nature:species_detail", kwargs={"slug": self.uuid})++    @property+    def subtitle(self):+        return self.scientific_name or ""++    @property+    def run_time_seconds(self) -> int:+        return self.base_run_time_seconds or 300++    @property+    def strings(self) -> ScrobblableConstants:+        return ScrobblableConstants(verb="Observing", tags="leaf")++    @property+    def logdata_cls(self):+        return SpeciesObservationLogData++    @property+    def primary_image_url(self) -> str:+        if self.photo:+            return self.photo.url+        return ""++    def fix_metadata(self) -> None:+        pass++    @classmethod+    def find_or_create(+        cls,+        common_name: str,+        scientific_name: str,+        taxon_id: int,+        iconic_taxon_name: str = "",+        rank: str = "",+        wikipedia_url: str = "",+    ) -> "SpeciesObservation":+        species = cls.objects.filter(taxon_id=taxon_id).first()+        if not species:+            species = cls.objects.create(+                title=common_name,+                scientific_name=scientific_name,+                taxon_id=taxon_id,+                iconic_taxon_name=iconic_taxon_name,+                rank=rank,+                wikipedia_url=wikipedia_url or "",+            )+        return speciesdiff --git a/vrobbler/apps/nature/tasks.py b/vrobbler/apps/nature/tasks.pynew file mode 100644index 0000000..82c0afd--- /dev/null+++ b/vrobbler/apps/nature/tasks.py@@ -0,0 +1,13 @@+import logging++from celery import shared_task++logger = logging.getLogger(__name__)+++@shared_task+def import_from_inaturalist_all_users():+    """Import iNaturalist observations for all users."""+    from nature.importer import import_observations_for_all_users++    import_observations_for_all_users()diff --git a/vrobbler/apps/nature/urls.py b/vrobbler/apps/nature/urls.pynew file mode 100644index 0000000..daa5da1--- /dev/null+++ b/vrobbler/apps/nature/urls.py@@ -0,0 +1,17 @@+from django.urls import path+from nature import views++app_name = "nature"++urlpatterns = [+    path(+        "observations/",+        views.SpeciesObservationListView.as_view(),+        name="species_observation_list",+    ),+    path(+        "observations/<slug:slug>/",+        views.SpeciesObservationDetailView.as_view(),+        name="species_observation_detail",+    ),+]diff --git a/vrobbler/apps/nature/views.py b/vrobbler/apps/nature/views.pynew file mode 100644index 0000000..3e064bb--- /dev/null+++ b/vrobbler/apps/nature/views.py@@ -0,0 +1,11 @@+from django.views import generic+from nature.models import SpeciesObservation+from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView+++class SpeciesObservationListView(ScrobbleableListView):+    model = SpeciesObservation+++class SpeciesObservationDetailView(ScrobbleableDetailView):+    model = SpeciesObservationdiff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.pyindex 448a54e..60f4c38 100644--- a/vrobbler/apps/profiles/forms.py+++ b/vrobbler/apps/profiles/forms.py@@ -26,6 +26,9 @@ class UserProfileForm(forms.ModelForm):             "archivebox_url",             "bgg_username",             "lichess_username",+            "inaturalist_username",+            "inaturalist_user_id",+            "inaturalist_auto_import",             "webdav_url",             "webdav_user",             "webdav_pass",diff --git a/vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.py b/vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.pynew file mode 100644index 0000000..2b5540b--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.py@@ -0,0 +1,28 @@+# Generated by Django 4.2.29 on 2026-07-17 02:42++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("profiles", "0043_add_water_reminder"),+    ]++    operations = [+        migrations.AddField(+            model_name="userprofile",+            name="inaturalist_auto_import",+            field=models.BooleanField(default=False),+        ),+        migrations.AddField(+            model_name="userprofile",+            name="inaturalist_user_id",+            field=models.IntegerField(blank=True, null=True),+        ),+        migrations.AddField(+            model_name="userprofile",+            name="inaturalist_username",+            field=models.CharField(blank=True, max_length=255, null=True),+        ),+    ]diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.pyindex 1ae9b69..b02254d 100644--- a/vrobbler/apps/profiles/models.py+++ b/vrobbler/apps/profiles/models.py@@ -154,6 +154,10 @@ class UserProfile(TimeStampedModel):         help_text="List of trend slugs the user has disabled",     ) +    inaturalist_username = models.CharField(max_length=255, blank=True, null=True)+    inaturalist_user_id = models.IntegerField(blank=True, null=True)+    inaturalist_auto_import = models.BooleanField(default=False)+     def __str__(self):         return f"User profile for {self.user}" diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex bc85c59..4a0fbf9 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -54,6 +54,7 @@ PLAY_AGAIN_MEDIA = {     "channels": "Channel",     "birds": "BirdingLocation",     "discgolf": "DiscGolfCourse",+    "nature": "SpeciesObservation", }  DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]diff --git a/vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.py b/vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.pynew file mode 100644index 0000000..34762fe--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.py@@ -0,0 +1,94 @@+# Generated by Django 4.2.29 on 2026-07-17 02:42++from django.db import migrations, models+import django.db.models.deletion+++class Migration(migrations.Migration):++    dependencies = [+        ("nature", "0001_initial"),+        ("scrobbles", "0102_favoritemedia_drink_scrobble_drink_and_more"),+    ]++    operations = [+        migrations.AddField(+            model_name="scrobble",+            name="species_observation",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="nature.speciesobservation",+            ),+        ),+        migrations.AlterField(+            model_name="favoritemedia",+            name="media_type",+            field=models.CharField(+                choices=[+                    ("Video", "Video"),+                    ("Track", "Track"),+                    ("PodcastEpisode", "Podcast episode"),+                    ("SportEvent", "Sport event"),+                    ("Book", "Book"),+                    ("Paper", "Paper"),+                    ("VideoGame", "Video game"),+                    ("BoardGame", "Board game"),+                    ("GeoLocation", "GeoLocation"),+                    ("Trail", "Trail"),+                    ("Beer", "Beer"),+                    ("Wine", "Wine"),+                    ("Coffee", "Coffee"),+                    ("Drink", "Drink"),+                    ("Puzzle", "Puzzle"),+                    ("Food", "Food"),+                    ("Task", "Task"),+                    ("WebPage", "Web Page"),+                    ("LifeEvent", "Life event"),+                    ("Mood", "Mood"),+                    ("BrickSet", "Brick set"),+                    ("Channel", "Channel"),+                    ("BirdingLocation", "Birding location"),+                    ("DiscGolfCourse", "Disc golf"),+                    ("SpeciesObservation", "Species observation"),+                ],+                max_length=20,+            ),+        ),+        migrations.AlterField(+            model_name="scrobble",+            name="media_type",+            field=models.CharField(+                choices=[+                    ("Video", "Video"),+                    ("Track", "Track"),+                    ("PodcastEpisode", "Podcast episode"),+                    ("SportEvent", "Sport event"),+                    ("Book", "Book"),+                    ("Paper", "Paper"),+                    ("VideoGame", "Video game"),+                    ("BoardGame", "Board game"),+                    ("GeoLocation", "GeoLocation"),+                    ("Trail", "Trail"),+                    ("Beer", "Beer"),+                    ("Wine", "Wine"),+                    ("Coffee", "Coffee"),+                    ("Drink", "Drink"),+                    ("Puzzle", "Puzzle"),+                    ("Food", "Food"),+                    ("Task", "Task"),+                    ("WebPage", "Web Page"),+                    ("LifeEvent", "Life event"),+                    ("Mood", "Mood"),+                    ("BrickSet", "Brick set"),+                    ("Channel", "Channel"),+                    ("BirdingLocation", "Birding location"),+                    ("DiscGolfCourse", "Disc golf"),+                    ("SpeciesObservation", "Species observation"),+                ],+                default="Video",+                max_length=20,+            ),+        ),+    ]diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 6ebab4a..53bfff8 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -9,24 +9,22 @@ from zoneinfo import ZoneInfo  import pendulum import pytz-from drinks.models import Beer, Wine, Coffee, Drink from birds.models import BirdingLocation-from discgolf.models import DiscGolfCourse from boardgames.models import BoardGame from books.koreader import process_koreader_sqlite_file from books.models import Book, BookLogData, BookPageLogData, Paper from bricksets.models import BrickSet from charts.utils import build_charts from dataclass_wizard.errors import ParseError+from discgolf.models import DiscGolfCourse from django.conf import settings-from scrobbles.constants import Visibility-from scrobbles.sqids import encode_scrobble_share from django.contrib.auth import get_user_model from django.core.files import File from django.db import models from django.urls import reverse from django.utils import timezone from django_extensions.db.models import TimeStampedModel+from drinks.models import Beer, Coffee, Drink, Wine from foods.models import Food, FoodLogData from imagekit.models import ImageSpecField from imagekit.processors import ResizeToFit@@ -34,6 +32,7 @@ from lifeevents.models import LifeEvent from locations.models import GeoLocation from moods.models import Mood from music.models import Artist, Track+from nature.models import SpeciesObservation from podcasts.models import PodcastEpisode from profiles.utils import (     end_of_day,@@ -50,12 +49,14 @@ from scrobbles.constants import (     AUTO_FINISH_MEDIA,     LONG_PLAY_MEDIA,     MEDIA_END_PADDING_SECONDS,+    Visibility, ) from scrobbles.importers.lastfm import LastFM from scrobbles.notifications import (     LastFmImportNtfyNotification,     ScrobbleNtfyNotification, )+from scrobbles.sqids import encode_scrobble_share from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key from sports.models import SportEvent from taggit.managers import TaggableManager@@ -697,6 +698,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {     "Channel": ("channel",),     "BirdingLocation": ("birding_location",),     "DiscGolfCourse": ("disc_golf_course",),+    "SpeciesObservation": ("species_observation",), }  @@ -728,6 +730,7 @@ class ScrobbleQuerySet(models.QuerySet):             "brick_set",             "birding_location",             "disc_golf_course",+            "species_observation",         )      def with_related_for_types(self, media_types: list[str]):@@ -782,6 +785,7 @@ class Scrobble(TimeStampedModel):         CHANNEL = "Channel", "Channel"         BIRDING_LOCATION = "BirdingLocation", "Birding location"         DISC_GOLF = "DiscGolfCourse", "Disc golf"+        SPECIES_OBSERVATION = "SpeciesObservation", "Species observation"          @classmethod         def list(cls):@@ -818,6 +822,9 @@ class Scrobble(TimeStampedModel):     disc_golf_course = models.ForeignKey(         DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL     )+    species_observation = models.ForeignKey(+        SpeciesObservation, on_delete=models.DO_NOTHING, **BNULL+    )     media_type = models.CharField(         max_length=20, choices=MediaType.choices, default=MediaType.VIDEO     )@@ -1402,6 +1409,8 @@ class Scrobble(TimeStampedModel):             media_obj = self.paper         if self.disc_golf_course:             media_obj = self.disc_golf_course+        if self.species_observation:+            media_obj = self.species_observation         return media_obj      def __str__(self):diff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex d6fc699..eb09a84 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -198,6 +198,10 @@ CELERY_BEAT_SCHEDULE = {         "task": "scrobbles.tasks.backfill_scrobble_sentiment",         "schedule": crontab(minute="0"),     },+    "import-from-inaturalist": {+        "task": "nature.tasks.import_from_inaturalist_all_users",+        "schedule": crontab(hour="*/4", minute=30),+    }, }  INSTALLED_APPS = [@@ -242,6 +246,7 @@ INSTALLED_APPS = [     "moods",     "discgolf",     "birds",+    "nature",     "mathfilters",     "rest_framework",     "allauth",diff --git a/vrobbler/templates/nature/species_observation_detail.html b/vrobbler/templates/nature/species_observation_detail.htmlnew file mode 100644index 0000000..ae2899c--- /dev/null+++ b/vrobbler/templates/nature/species_observation_detail.html@@ -0,0 +1,52 @@+{% extends "base_list.html" %}++{% block title %}{{ object.title }}{% endblock %}++{% block lists %}++<div class="row">+    <div class="summary">+        {% if object.photo %}+        <div style="float: left; margin-right: 15px; margin-bottom: 15px;">+            <img src="{{ object.photo.url }}" style="max-width: 300px; max-height: 300px; object-fit: contain;">+        </div>+        {% endif %}+        {% if object.scientific_name %}+        <p><em>{{ object.scientific_name }}</em></p>+        {% endif %}+        <p><strong>Taxon ID:</strong> {{ object.taxon_id }}</p>+        <p><a href="https://www.inaturalist.org/taxa/{{ object.taxon_id }}" target="_blank">View on iNaturalist</a></p>+        <hr />+    </div>+</div>+<div class="row">+    <p>{{ scrobbles.count }} observations</p>+</div>+<div class="row">+    <div class="col-md">+        <h3>Recent observations</h3>+        <div class="table-responsive">+            <table class="table table-striped table-sm">+                <thead>+                    <tr>+                        <th scope="col">Date</th>+                        <th scope="col">Location</th>+                        <th scope="col">Quality</th>+                        <th scope="col">Source</th>+                    </tr>+                </thead>+                <tbody>+                    {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}+                    <tr>+                        <td><a href="{{ scrobble.get_absolute_url }}">{{ scrobble.local_timestamp }}</a></td>+                        <td>{{ scrobble.logdata.place_guess|default:"—" }}</td>+                        <td>{{ scrobble.logdata.quality_grade|default:"—" }}</td>+                        <td>{{ scrobble.source }}</td>+                    </tr>+                    {% endfor %}+                </tbody>+            </table>+        </div>+    </div>+</div>+{% endblock %}diff --git a/vrobbler/templates/nature/species_observation_list.html b/vrobbler/templates/nature/species_observation_list.htmlnew file mode 100644index 0000000..3484865--- /dev/null+++ b/vrobbler/templates/nature/species_observation_list.html@@ -0,0 +1,10 @@+{% extends "base_list.html" %}+{% block title %}Species Observed{% endblock %}++{% block lists %}+<div class="row">+  <div class="col-md">+    <div class="table-responsive">{% include "_scrobblable_list.html" %}</div>+  </div>+</div>+{% endblock %}diff --git a/vrobbler/urls.py b/vrobbler/urls.pyindex 894fa48..74586d8 100644--- a/vrobbler/urls.py+++ b/vrobbler/urls.py@@ -43,6 +43,8 @@ from vrobbler.apps.locations import urls as locations_urls from vrobbler.apps.locations.api.views import GeoLocationViewSet from vrobbler.apps.moods import urls as moods_urls from vrobbler.apps.moods.api.views import MoodViewSet+from vrobbler.apps.nature import urls as nature_urls+from vrobbler.apps.nature.api.views import SpeciesObservationViewSet from vrobbler.apps.music import urls as music_urls from vrobbler.apps.music.api.views import (     AlbumViewSet,@@ -148,6 +150,7 @@ router.register(r"lifeevents", LifeEventViewSet) router.register(r"birds", BirdViewSet) router.register(r"birding-locations", BirdingLocationViewSet) router.register(r"disc-golf-courses", DiscGolfCourseViewSet)+router.register(r"observations", SpeciesObservationViewSet) urlpatterns = [     path("api/v1/", include(router.urls)),     path("api/v1/auth", include("rest_framework.urls")),@@ -174,6 +177,7 @@ urlpatterns = [     path("", include(lifeevents_urls, namespace="life-events")),     path("", include(moods_urls, namespace="moods")),     path("", include(birds_urls, namespace="birds")),+    path("", include(nature_urls, namespace="nature")),     path("", include(scrobble_urls, namespace="scrobbles")),     path("", include(profiles_urls, namespace="profiles")),     path("", include(people_urls, namespace="people")),