sessions / 1f5fada8b1096e0282cf606610752345f7cddde8

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

This commit has no recorded session.

diff

ignore whitespace

commit 1f5fada8b1096e0282cf606610752345f7cddde8Author: Colin Powell <colin@unbl.ink>Date:   Sat Jun 20 00:37:18 2026 -0400    [discgolf] Add new scrobble typediff --git a/PROJECT.org b/PROJECT.orgindex 521c118..4c90ae5 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 [1/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,46 @@ independent of the email flow it was originally creatdd for  ** TODO [#B] Is there way to create unique slugs for media instances :media_types: +** DONE [#B] Add DiscGolf as a scrobbleable media :discgolf:+:PROPERTIES:+:ID:       8cdde5d3-0ae5-7d5a-99d2-200c86afae03+:END:++*** Description++I have a csv file fro the uDisc disc golf scoring app that looks like:++Singles round. Note second row is the par for the course+#+begin_src csv+PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9+Par,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,27,,,3,3,3,3,3,3,3,3,3+Colin Powell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,30,3,,2,3,4,4,3,4,3,3,4+Asa Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,5,4,4,8,5,5,4,4,5+Emma Sweet,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,4,5,6,3,4,3,5,6+Jane Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,4,5,5,5,5,5,4,6,5+Nabby Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,59,32,,6,6,7,7,6,7,6,6,8+Silas Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,5,4,5,3,5,4,4,6`+#+end_src++Teams of two or more persons. Note second row is the par for the course+#+begin_src csv+PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9+Par,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,27,,,3,3,3,3,3,3,3,3,3+Colin Powell + Asa Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,29,2,,3,4,2,3,2,3,5,3,4+Emma Sweet + Jane Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,28,1,,4,3,4,2,3,4,3,3,2+#+end_src++We should add a new app called discgolf that has the following data models:++- DiscGolfRound - scrobblable media + course_id, round_type (Singles, Teams)+- DiscGolfCourse - name, layoutname, number_of_holes++And the logdata for a DiscGolfOuting scrobble should have:+  + {person: {hole_number: score}, total: int}+  + {team: {name: "", people: [person, person], hole_number: score}, total: int}+  + weather+  + fun_factor (miserable, not great, so-so, good, excellent,  party time)+ * Version 55.6 [1/1] ** DONE [#A] Figure out why historical Lastfm imports don't work :importers:lastfm:music: :PROPERTIES:diff --git a/vrobbler/apps/discgolf/__init__.py b/vrobbler/apps/discgolf/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/discgolf/admin.py b/vrobbler/apps/discgolf/admin.pynew file mode 100644index 0000000..3291aae--- /dev/null+++ b/vrobbler/apps/discgolf/admin.py@@ -0,0 +1,14 @@+from discgolf.models import DiscGolfCourse+from django.contrib import admin+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")+    raw_id_fields = ("trail",)+    search_fields = ("title", "layout_name")+    inlines = [+        ScrobbleInline,+    ]diff --git a/vrobbler/apps/discgolf/api/__init__.py b/vrobbler/apps/discgolf/api/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/discgolf/api/serializers.py b/vrobbler/apps/discgolf/api/serializers.pynew file mode 100644index 0000000..b11c9f2--- /dev/null+++ b/vrobbler/apps/discgolf/api/serializers.py@@ -0,0 +1,11 @@+from discgolf import models+from rest_framework import serializers+++class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = models.DiscGolfCourse+        fields = "__all__"+++diff --git a/vrobbler/apps/discgolf/api/views.py b/vrobbler/apps/discgolf/api/views.pynew file mode 100644index 0000000..328a3e9--- /dev/null+++ b/vrobbler/apps/discgolf/api/views.py@@ -0,0 +1,13 @@+from rest_framework import permissions, viewsets++from discgolf.api import serializers+from discgolf import models+++class DiscGolfCourseViewSet(viewsets.ModelViewSet):+    queryset = models.DiscGolfCourse.objects.all().order_by("-created")+    serializer_class = serializers.DiscGolfCourseSerializer+    permission_classes = [permissions.IsAuthenticated]+++diff --git a/vrobbler/apps/discgolf/apps.py b/vrobbler/apps/discgolf/apps.pynew file mode 100644index 0000000..8778fc8--- /dev/null+++ b/vrobbler/apps/discgolf/apps.py@@ -0,0 +1,6 @@+from django.apps import AppConfig+++class DiscgolfConfig(AppConfig):+    default_auto_field = "django.db.models.BigAutoField"+    name = "discgolf"diff --git a/vrobbler/apps/discgolf/management/__init__.py b/vrobbler/apps/discgolf/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/discgolf/management/commands/__init__.py b/vrobbler/apps/discgolf/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/discgolf/migrations/0001_initial.py b/vrobbler/apps/discgolf/migrations/0001_initial.pynew file mode 100644index 0000000..d5d753b--- /dev/null+++ b/vrobbler/apps/discgolf/migrations/0001_initial.py@@ -0,0 +1,83 @@+# Generated by Django 4.2.29 on 2026-06-20 04:19++from django.db import migrations, models+import django_extensions.db.fields+import taggit.managers+import uuid+++class Migration(migrations.Migration):++    initial = True++    dependencies = [+        ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),+        ("scrobbles", "0098_scrobble_long_play_last_scrobble"),+    ]++    operations = [+        migrations.CreateModel(+            name="DiscGolfCourse",+            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)),+                ("description", models.TextField(blank=True, null=True)),+                (+                    "layout_name",+                    models.CharField(blank=True, max_length=255, null=True),+                ),+                ("number_of_holes", models.IntegerField(blank=True, null=True)),+                ("par_total", models.IntegerField(blank=True, null=True)),+                (+                    "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/discgolf/migrations/0002_discgolfcourse_trail.py b/vrobbler/apps/discgolf/migrations/0002_discgolfcourse_trail.pynew file mode 100644index 0000000..2143072--- /dev/null+++ b/vrobbler/apps/discgolf/migrations/0002_discgolfcourse_trail.py@@ -0,0 +1,25 @@+# Generated by Django 4.2.29 on 2026-06-20 04:29++from django.db import migrations, models+import django.db.models.deletion+++class Migration(migrations.Migration):++    dependencies = [+        ("trails", "0009_trail_route_waypoint"),+        ("discgolf", "0001_initial"),+    ]++    operations = [+        migrations.AddField(+            model_name="discgolfcourse",+            name="trail",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="trails.trail",+            ),+        ),+    ]diff --git a/vrobbler/apps/discgolf/migrations/0003_discgolfcourse_par_per_hole.py b/vrobbler/apps/discgolf/migrations/0003_discgolfcourse_par_per_hole.pynew file mode 100644index 0000000..9210340--- /dev/null+++ b/vrobbler/apps/discgolf/migrations/0003_discgolfcourse_par_per_hole.py@@ -0,0 +1,18 @@+# Generated by Django 4.2.29 on 2026-06-20 04:35++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("discgolf", "0002_discgolfcourse_trail"),+    ]++    operations = [+        migrations.AddField(+            model_name="discgolfcourse",+            name="par_per_hole",+            field=models.JSONField(blank=True, null=True),+        ),+    ]diff --git a/vrobbler/apps/discgolf/migrations/__init__.py b/vrobbler/apps/discgolf/migrations/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/discgolf/models.py b/vrobbler/apps/discgolf/models.pynew file mode 100644index 0000000..ba47d65--- /dev/null+++ b/vrobbler/apps/discgolf/models.py@@ -0,0 +1,76 @@+from dataclasses import dataclass+from typing import Optional, TypedDict++from django.apps import apps+from django.db import models+from django.urls import reverse+from scrobbles.dataclasses import BaseLogData, WithPeopleLogData+from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin++BNULL = {"blank": True, "null": True}+++class DiscGolfSingleScores(TypedDict, total=False):+    person_id: int+    total: int+++class DiscGolfTeamScores(TypedDict, total=False):+    person_ids: list[int]+    total: int+++@dataclass+class DiscGolfLogData(BaseLogData, WithPeopleLogData):+    scores: Optional[dict[str, DiscGolfSingleScores | DiscGolfTeamScores]] = None+    weather: Optional[str] = None+    fun_factor: Optional[str] = None+    course_name: Optional[str] = None+    par: Optional[int] = None+    round_type: Optional[str] = None+++class DiscGolfCourse(ScrobblableMixin):+    description = models.TextField(**BNULL)+    layout_name = models.CharField(max_length=255, **BNULL)+    number_of_holes = models.IntegerField(**BNULL)+    par_total = models.IntegerField(**BNULL)+    par_per_hole = models.JSONField(**BNULL)+    trail = models.ForeignKey(+        "trails.Trail", on_delete=models.DO_NOTHING, **BNULL+    )++    def get_absolute_url(self) -> str:+        return reverse("discgolf:course_detail", kwargs={"slug": self.uuid})++    def __str__(self):+        return f"{self.title} ({self.layout_name or 'Default'})"++    @property+    def logdata_cls(self):+        return DiscGolfLogData++    @property+    def subtitle(self):+        return self.layout_name or ""++    @property+    def strings(self) -> ScrobblableConstants:+        return ScrobblableConstants(verb="Playing", tags="golf")++    @property+    def primary_image_url(self) -> str:+        return ""++    @classmethod+    def find_or_create(cls, name: str, **defaults) -> "DiscGolfCourse":+        course = cls.objects.filter(title=name).first()+        if not course:+            course = cls.objects.create(title=name, **defaults)+        return course++    def scrobbles(self, user_id):+        Scrobble = apps.get_model("scrobbles", "Scrobble")+        return Scrobble.objects.filter(user_id=user_id, disc_golf=self).order_by(+            "-timestamp"+        )diff --git a/vrobbler/apps/discgolf/urls.py b/vrobbler/apps/discgolf/urls.pynew file mode 100644index 0000000..ae5063b--- /dev/null+++ b/vrobbler/apps/discgolf/urls.py@@ -0,0 +1,14 @@+from django.urls import path+from discgolf import views++app_name = "discgolf"+++urlpatterns = [+    path("disc-golf/", views.DiscGolfCourseListView.as_view(), name="course_list"),+    path(+        "disc-golf/<slug:slug>/",+        views.DiscGolfCourseDetailView.as_view(),+        name="course_detail",+    ),+]diff --git a/vrobbler/apps/discgolf/utils.py b/vrobbler/apps/discgolf/utils.pynew file mode 100644index 0000000..11a4172--- /dev/null+++ b/vrobbler/apps/discgolf/utils.py@@ -0,0 +1,126 @@+import csv+import logging+from datetime import datetime++from dateutil.parser import parse as parse_datetime+from django.utils import timezone+from people.models import Person+from scrobbles.models import Scrobble+from scrobbles.notifications import ScrobbleNtfyNotification++logger = logging.getLogger(__name__)+++def _parse_udisc_datetime(raw: str) -> datetime:+    return parse_datetime(raw)+++def _resolve_player(name: str) -> Person:+    person, _ = Person.objects.get_or_create(name=name.strip())+    return person+++def import_udisc_csv(+    file_path: str, user_id: int, record_error=None+) -> list[Scrobble]:+    from discgolf.models import DiscGolfCourse++    new_scrobbles = []++    with open(file_path, newline="", encoding="utf-8-sig") as f:+        reader = csv.DictReader(f)+        rows = list(reader)++    if not rows:+        return []++    par_row = None+    player_rows = []+    for row in rows:+        name = row.get("PlayerName", "").strip()+        if name.lower() == "par":+            par_row = row+        else:+            player_rows.append(row)++    if not par_row:+        return []++    course_name = par_row.get("CourseName", "").strip()+    layout_name = par_row.get("LayoutName", "").strip()+    start_date_raw = par_row.get("StartDate", "").strip()+    start_dt = _parse_udisc_datetime(start_date_raw) if start_date_raw else timezone.now()++    number_of_holes = sum(1 for k in par_row if k.startswith("Hole") and k[4:].isdigit())+    par_total_str = par_row.get("Total", "").strip()+    par_total = int(par_total_str) if par_total_str.isdigit() else None++    par_per_hole = {}+    for k, v in par_row.items():+        if k.startswith("Hole") and k[4:].isdigit() and v:+            hole_num = int(k[4:])+            try:+                par_per_hole[f"hole_{hole_num}"] = int(v)+            except (ValueError, TypeError):+                pass++    course, _ = DiscGolfCourse.objects.get_or_create(+        title=course_name,+        defaults={+            "layout_name": layout_name,+            "number_of_holes": number_of_holes,+            "par_total": par_total,+            "par_per_hole": par_per_hole or None,+        },+    )++    is_teams = "+" in player_rows[0].get("PlayerName", "") if player_rows else False+    round_type = "Teams" if is_teams else "Singles"++    scores = {}+    for row in player_rows:+        player_name = row.get("PlayerName", "").strip()+        hole_scores = {}+        for k, v in row.items():+            if k.startswith("Hole") and k[4:].isdigit() and v:+                hole_num = int(k[4:])+                try:+                    hole_scores[f"hole_{hole_num}"] = int(v)+                except (ValueError, TypeError):+                    pass+        total_str = row.get("Total", "").strip()+        total = int(total_str) if total_str.isdigit() else None+        if total is not None:+            hole_scores["total"] = total++        if is_teams:+            people = player_name.split("+")+            person_ids = [_resolve_player(p.strip()).id for p in people]+            hole_scores["person_ids"] = person_ids+        else:+            person = _resolve_player(player_name)+            hole_scores["person_id"] = person.id++        scores[player_name] = hole_scores++    log = {+        "scores": scores,+        "course_name": course_name,+        "par": par_total,+        "round_type": round_type,+    }++    scrobble_dict = {+        "user_id": user_id,+        "timestamp": start_dt,+        "source": "uDisc",+        "playback_position_seconds": 0,+        "log": log,+    }++    scrobble = Scrobble.create_or_update(course, user_id, scrobble_dict)+    if scrobble:+        new_scrobbles.append(scrobble)+        ScrobbleNtfyNotification(scrobble).send()++    return new_scrobblesdiff --git a/vrobbler/apps/discgolf/views.py b/vrobbler/apps/discgolf/views.pynew file mode 100644index 0000000..9498d24--- /dev/null+++ b/vrobbler/apps/discgolf/views.py@@ -0,0 +1,15 @@+from discgolf.models import DiscGolfCourse++from scrobbles.views import (+    ScrobbleableListView,+    ScrobbleableDetailView,+    ChartContextMixin,+)+++class DiscGolfCourseListView(ScrobbleableListView):+    model = DiscGolfCourse+++class DiscGolfCourseDetailView(ScrobbleableDetailView, ChartContextMixin):+    model = DiscGolfCoursediff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.pyindex 2132c8d..93338bd 100644--- a/vrobbler/apps/scrobbles/admin.py+++ b/vrobbler/apps/scrobbles/admin.py@@ -12,6 +12,7 @@ from scrobbles.models import (     Scrobble,     ShareViewLog,     TrailGPXImport,+    UDiscCSVImport, ) from scrobbles.mixins import Genre @@ -73,6 +74,10 @@ class ScaleCSVImportAdmin(ImportBaseAdmin): ... class TrailGPXImportAdmin(ImportBaseAdmin): ...  +@admin.register(UDiscCSVImport)+class UDiscCSVImportAdmin(ImportBaseAdmin): ...++ @admin.register(Genre) class GenreAdmin(admin.ModelAdmin):     list_display = (@@ -118,6 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin):         "web_page",         "life_event",         "birding_location",+        "disc_golf",         "long_play_last_scrobble",     )     list_filter = (@@ -179,4 +185,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin):         "web_page",         "life_event",         "birding_location",+        "disc_golf",     )diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex 877ad73..b7a3a6c 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -36,6 +36,7 @@ PLAY_AGAIN_MEDIA = {     "locations": "GeoLocation",     "videos": "Video",     "birds": "BirdingLocation",+    "discgolf": "DiscGolf", }  MEDIA_END_PADDING_SECONDS = {@@ -73,6 +74,7 @@ MANUAL_SCROBBLE_FNS = {     "-c": "manual_scrobble_book",     "-f": "manual_scrobble_food",     "-h": "manual_scrobble_twitch_channel",+    "-dg": "manual_scrobble_discgolf", }  diff --git a/vrobbler/apps/scrobbles/importers/webdav.py b/vrobbler/apps/scrobbles/importers/webdav.pyindex d3c3f37..b0640de 100644--- a/vrobbler/apps/scrobbles/importers/webdav.py+++ b/vrobbler/apps/scrobbles/importers/webdav.py@@ -19,6 +19,7 @@ DEFAULT_RETROARCH_PATH = "var/retroarch/" DEFAULT_BGSTATS_PATH = "var/bgstats/" DEFAULT_EBIRD_PATH = "var/ebird/" DEFAULT_SCALE_PATH = "var/scale/"+DEFAULT_UDISC_PATH = "var/udisc/"   def import_from_webdav_for_all_users(@@ -48,6 +49,7 @@ def import_from_webdav_for_all_users(     bgstats_count = 0     ebird_count = 0     scale_count = 0+    udisc_count = 0      for user_id in webdav_enabled_user_ids:         client = get_webdav_client(user_id)@@ -78,9 +80,13 @@ def import_from_webdav_for_all_users(         )         logger.info("Scanning WebDAV scale for user %s", user_id)         scale_count += scan_webdav_for_scale(client, user_id)+        logger.info("Scanning WebDAV udisc for user %s", user_id)+        udisc_count += scan_webdav_for_udisc(+            client, user_id, include_processed=include_processed+        )      logger.info(-        "Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale WebDAV imports",+        "Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale, %d uDisc WebDAV imports",         ko_count,         gpx_count,         retro_count,@@ -94,9 +100,10 @@ def import_from_webdav_for_all_users(             "bgstats": bgstats_count,             "ebird": ebird_count,             "scale": scale_count,+            "udisc": udisc_count,         },     )-    return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count+    return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count, udisc_count   def scan_webdav_for_koreader(@@ -811,3 +818,115 @@ def scan_webdav_for_scale(webdav_client, user_id):             os.unlink(tmp.name)      return new_imports+++def scan_webdav_for_udisc(webdav_client, user_id, include_processed=False):+    """Download .csv files from WebDAV var/udisc/ and queue imports for new files.++    After importing, files are moved to var/udisc/processed/ so they are+    not re-imported on subsequent scans unless *include_processed* is True.+    """+    from scrobbles.models import UDiscCSVImport+    from scrobbles.tasks import process_udisc_csv_import++    udisc_path = DEFAULT_UDISC_PATH+    try:+        webdav_client.info(udisc_path)+    except:+        logger.info("No var/udisc/ directory on webdav", extra={"user_id": user_id})+        return 0++    try:+        files = webdav_client.list(udisc_path)+    except Exception as e:+        logger.warning(+            "Could not list var/udisc/",+            extra={"user_id": user_id, "error": str(e)},+        )+        return 0++    processed_dir = f"{udisc_path}processed/"+    try:+        webdav_client.mkdir(processed_dir, recursive=True)+    except Exception:+        pass++    new_imports = 0+    already_imported = set(+        UDiscCSVImport.objects.filter(user_id=user_id).values_list(+            "original_filename", flat=True+        )+    )++    for fname in files:+        fname = os.path.basename(fname)+        if not fname.lower().endswith(".csv"):+            continue+        if fname == "processed":+            continue+        if fname in already_imported:+            logger.debug(f"Skipping already-imported {fname}")+            continue++        tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)+        try:+            webdav_client.download_sync(+                remote_path=f"{udisc_path}{fname}", local_path=tmp.name+            )+            imp = UDiscCSVImport.objects.create(+                user_id=user_id,+                original_filename=fname,+            )+            with open(tmp.name, "rb") as f:+                imp.csv_file.save(fname, f, save=True)++            stem, ext = os.path.splitext(fname)+            ts = datetime.now().strftime("%Y%m%dT%H%M%S")+            webdav_client.move(+                f"{udisc_path}{fname}",+                f"{processed_dir}{stem}_{ts}{ext}",+            )++            process_udisc_csv_import.delay(imp.id)+            new_imports += 1+        except Exception as e:+            logger.error(f"Failed to import uDisc CSV file {fname}: {e}")+        finally:+            os.unlink(tmp.name)++    if include_processed:+        try:+            processed_files = webdav_client.list(processed_dir)+        except Exception as e:+            logger.warning(+                "Could not list var/udisc/processed/",+                extra={"user_id": user_id, "error": str(e)},+            )+            return new_imports++        for fname in processed_files:+            fname = os.path.basename(fname)+            if not fname.lower().endswith(".csv"):+                continue++            tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)+            try:+                webdav_client.download_sync(+                    remote_path=f"{processed_dir}{fname}", local_path=tmp.name+                )+                imp = UDiscCSVImport.objects.create(+                    user_id=user_id,+                    original_filename=fname,+                )+                with open(tmp.name, "rb") as f:+                    imp.csv_file.save(fname, f, save=True)+                process_udisc_csv_import.delay(imp.id)+                new_imports += 1+            except Exception as e:+                logger.error(+                    f"Failed to import processed uDisc CSV file {fname}: {e}"+                )+            finally:+                os.unlink(tmp.name)++    return new_importsdiff --git a/vrobbler/apps/scrobbles/migrations/0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more.py b/vrobbler/apps/scrobbles/migrations/0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more.pynew file mode 100644index 0000000..f2b1adb--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more.py@@ -0,0 +1,156 @@+# Generated by Django 4.2.29 on 2026-06-20 04:19++from django.conf import settings+from django.db import migrations, models+import django.db.models.deletion+import django_extensions.db.fields+import scrobbles.models+import uuid+++class Migration(migrations.Migration):++    dependencies = [+        ("discgolf", "0001_initial"),+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),+        ("scrobbles", "0098_scrobble_long_play_last_scrobble"),+    ]++    operations = [+        migrations.AddField(+            model_name="favoritemedia",+            name="disc_golf",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.CASCADE,+                to="discgolf.discgolfcourse",+            ),+        ),+        migrations.AddField(+            model_name="scrobble",+            name="disc_golf",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="discgolf.discgolfcourse",+            ),+        ),+        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"),+                    ("Puzzle", "Puzzle"),+                    ("Food", "Food"),+                    ("Task", "Task"),+                    ("WebPage", "Web Page"),+                    ("LifeEvent", "Life event"),+                    ("Mood", "Mood"),+                    ("BrickSet", "Brick set"),+                    ("Channel", "Channel"),+                    ("DiscGolf", "Disc golf"),+                ],+                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"),+                    ("Puzzle", "Puzzle"),+                    ("Food", "Food"),+                    ("Task", "Task"),+                    ("WebPage", "Web Page"),+                    ("LifeEvent", "Life event"),+                    ("Mood", "Mood"),+                    ("BrickSet", "Brick set"),+                    ("Channel", "Channel"),+                    ("DiscGolf", "Disc golf"),+                ],+                default="Video",+                max_length=20,+            ),+        ),+        migrations.CreateModel(+            name="UDiscCSVImport",+            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(default=uuid.uuid4, editable=False)),+                ("processing_started", models.DateTimeField(blank=True, null=True)),+                ("processed_finished", models.DateTimeField(blank=True, null=True)),+                ("process_log", models.TextField(blank=True, null=True)),+                ("process_count", models.IntegerField(blank=True, null=True)),+                ("error_log", models.TextField(blank=True, null=True)),+                (+                    "csv_file",+                    models.FileField(+                        blank=True,+                        null=True,+                        upload_to=scrobbles.models.UDiscCSVImport.get_path,+                    ),+                ),+                (+                    "original_filename",+                    models.CharField(blank=True, max_length=255, null=True),+                ),+                (+                    "user",+                    models.ForeignKey(+                        blank=True,+                        null=True,+                        on_delete=django.db.models.deletion.DO_NOTHING,+                        related_name="scrobbles_udisccsvimport_set",+                        to=settings.AUTH_USER_MODEL,+                    ),+                ),+            ],+            options={+                "verbose_name": "uDisc CSV Import",+            },+        ),+    ]diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 1fe9989..bf12b86 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -11,6 +11,7 @@ import pendulum import pytz from beers.models import Beer 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@@ -617,6 +618,60 @@ class EBirdCSVImport(BaseFileImportMixin):             self.mark_finished()  +class UDiscCSVImport(BaseFileImportMixin):+    class Meta:+        verbose_name = "uDisc CSV Import"++    user = models.ForeignKey(+        User,+        on_delete=models.DO_NOTHING,+        **BNULL,+        related_name="scrobbles_udisccsvimport_set",+    )++    @property+    def import_type(self) -> str:+        return "uDisc"++    def get_absolute_url(self):+        return reverse("scrobbles:udisc-csv-import-detail", kwargs={"slug": self.uuid})++    def get_path(instance, filename):+        extension = filename.split(".")[-1]+        uuid = instance.uuid+        return f"udisc-csv-uploads/{uuid}.{extension}"++    @property+    def upload_file_path(self):+        if getattr(settings, "USE_S3_STORAGE"):+            path = self.csv_file.url+        else:+            path = self.csv_file.path+        return path++    csv_file = models.FileField(upload_to=get_path, **BNULL)+    original_filename = models.CharField(max_length=255, **BNULL)++    def process(self, force=False):+        from discgolf.utils import import_udisc_csv++        if self.processed_finished and not force:+            logger.info(f"{self} already processed on {self.processed_finished}")+            return++        self.mark_started()+        try:+            scrobbles = import_udisc_csv(+                self.upload_file_path, self.user_id, record_error=self.record_error+            )+            self.record_log(scrobbles)+        except Exception as e:+            self.record_error(f"Import failed: {e}")+            logger.exception(f"Import failed for {self}")+        finally:+            self.mark_finished()++ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {     "Video": ("video",),     "Track": ("track", "track__artist_fk"),@@ -638,6 +693,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {     "BrickSet": ("brick_set",),     "Channel": ("channel",),     "BirdingLocation": ("birding_location",),+    "DiscGolf": ("disc_golf",), }  @@ -665,6 +721,7 @@ class ScrobbleQuerySet(models.QuerySet):             "mood",             "brick_set",             "birding_location",+            "disc_golf",         )      def with_related_for_types(self, media_types: list[str]):@@ -714,7 +771,7 @@ class Scrobble(TimeStampedModel):         MOOD = "Mood", "Mood"         BRICKSET = "BrickSet", "Brick set"         CHANNEL = "Channel", "Channel"-        BIRDING_LOCATION = "BirdingLocation", "Birding location"+        DISC_GOLF = "DiscGolf", "Disc golf"          @classmethod         def list(cls):@@ -745,6 +802,9 @@ class Scrobble(TimeStampedModel):     birding_location = models.ForeignKey(         BirdingLocation, on_delete=models.DO_NOTHING, **BNULL     )+    disc_golf = models.ForeignKey(+        DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL+    )     media_type = models.CharField(         max_length=20, choices=MediaType.choices, default=MediaType.VIDEO     )@@ -1316,6 +1376,8 @@ class Scrobble(TimeStampedModel):             media_obj = self.channel         if self.birding_location:             media_obj = self.birding_location+        if self.disc_golf:+            media_obj = self.disc_golf         return media_obj      def __str__(self):@@ -1874,6 +1936,9 @@ class FavoriteMedia(TimeStampedModel):     birding_location = models.ForeignKey(         BirdingLocation, on_delete=models.CASCADE, **BNULL     )+    disc_golf = models.ForeignKey(+        DiscGolfCourse, on_delete=models.CASCADE, **BNULL+    )     media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)     sent_to_mopidy = models.BooleanField(default=False) @@ -1924,6 +1989,8 @@ class FavoriteMedia(TimeStampedModel):             media_obj = self.channel         if self.birding_location:             media_obj = self.birding_location+        if self.disc_golf:+            media_obj = self.disc_golf         return media_obj      @classmethod@@ -1953,6 +2020,7 @@ class FavoriteMedia(TimeStampedModel):             "Mood": "mood",             "BrickSet": "brick_set",             "BirdingLocation": "birding_location",+            "DiscGolf": "disc_golf",         }          fk = fk_map.get(media_type)diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.pyindex e1a007f..14e37b7 100644--- a/vrobbler/apps/scrobbles/scrobblers.py+++ b/vrobbler/apps/scrobbles/scrobblers.py@@ -13,6 +13,7 @@ from books.models import Book, BookLogData, BookPageLogData from books.utils import parse_readcomicsonline_uri from bricksets.models import BrickSet from dateutil.parser import parse+from discgolf.models import DiscGolfCourse from django.utils import timezone from foods.models import Food from foods.sources.rscraper import RecipeScraperService@@ -1327,3 +1328,32 @@ def manual_scrobble_food(     )      return Scrobble.create_or_update(food, user_id, scrobble_dict)+++def manual_scrobble_discgolf(+    item_id: str,+    user_id: int,+    source: str = "Vrobbler",+    action: Optional[str] = None,+):+    from discgolf.models import DiscGolfCourse++    course, _ = DiscGolfCourse.objects.get_or_create(title=item_id.strip())++    scrobble_dict = {+        "user_id": user_id,+        "timestamp": timezone.now(),+        "playback_position_seconds": 0,+        "source": source,+    }++    logger.info(+        "[scrobblers] manual disc golf scrobble request received",+        extra={+            "course_id": course.id,+            "user_id": user_id,+            "scrobble_dict": scrobble_dict,+        },+    )++    return Scrobble.create_or_update(course, user_id, scrobble_dict)diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.pyindex a245129..0fcf512 100644--- a/vrobbler/apps/scrobbles/tasks.py+++ b/vrobbler/apps/scrobbles/tasks.py@@ -171,6 +171,16 @@ def process_ebird_csv_import(import_id):     birding_import.process()  +@shared_task+def process_udisc_csv_import(import_id):+    UDiscCSVImport = apps.get_model("scrobbles", "UDiscCSVImport")+    udisc_import = UDiscCSVImport.objects.filter(id=import_id).first()+    if not udisc_import:+        logger.warn(f"UDiscCSVImport not found with id {import_id}")+        return+    udisc_import.process()++ @shared_task def process_scale_csv_import(import_id):     ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.pyindex b36ce73..6679726 100644--- a/vrobbler/apps/scrobbles/urls.py+++ b/vrobbler/apps/scrobbles/urls.py@@ -147,6 +147,11 @@ urlpatterns = [         views.ScrobbleBirdingCSVImportDetailView.as_view(),         name="ebird-csv-import-detail",     ),+    path(+        "imports/udisc-csv/<slug:slug>/",+        views.ScrobbleUDiscCSVImportDetailView.as_view(),+        name="udisc-csv-import-detail",+    ),     path(         "long-plays/",         views.ScrobbleLongPlaysView.as_view(),diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex b24fcb5..dfc1f7c 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -87,6 +87,7 @@ from scrobbles.models import (     ScrobbleQuerySet,     ShareViewLog,     TrailGPXImport,+    UDiscCSVImport, ) from scrobbles.scrobblers import * from scrobbles.tasks import (@@ -535,6 +536,8 @@ class BaseScrobbleImportDetailView(DetailView):             title = "Scale CSV Import"         if self.model == TrailGPXImport:             title = "Trail GPX Import"+        if self.model == UDiscCSVImport:+            title = "uDisc CSV Import"         context_data["title"] = title         return context_data @@ -571,6 +574,10 @@ class ScrobbleBirdingCSVImportDetailView(BaseScrobbleImportDetailView):     model = EBirdCSVImport  +class ScrobbleUDiscCSVImportDetailView(BaseScrobbleImportDetailView):+    model = UDiscCSVImport++ class ManualScrobbleView(FormView):     form_class = ScrobbleForm     template_name = "scrobbles/manual_form.html"@@ -1108,6 +1115,7 @@ def toggle_favorite(request, media_type, object_id):         "Mood": ("moods", "Mood"),         "BrickSet": ("bricksets", "BrickSet"),         "BirdingLocation": ("birds", "BirdingLocation"),+        "DiscGolf": ("discgolf", "DiscGolfCourse"),     }      app_label, model_name = app_model_map.get(media_type, (None, None))diff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex 7fad0e0..28280e8 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -228,6 +228,7 @@ INSTALLED_APPS = [     "foods",     "lifeevents",     "moods",+    "discgolf",     "birds",     "mathfilters",     "rest_framework",diff --git a/vrobbler/templates/discgolf/discgolfcourse_detail.html b/vrobbler/templates/discgolf/discgolfcourse_detail.htmlnew file mode 100644index 0000000..d087772--- /dev/null+++ b/vrobbler/templates/discgolf/discgolfcourse_detail.html@@ -0,0 +1,44 @@+{% extends "base_list.html" %}+{% load static %}++{% block title %}{{object.title}}{% endblock %}++{% block lists %}+{% if object.description %}+<div class="row">+    <div class="col-md">+        <p>{{object.description}}</p>+    </div>+</div>+{% endif %}+{% if charts %}+<div class="row">+    <div class="col-md">+        {% include "scrobbles/_chart_links.html" %}+    </div>+</div>+{% endif %}+<div class="row">+    <div class="col-md">+        <h3>Last scrobbles</h3>+        <div class="table-responsive">+            <table class="table table-striped table-sm">+            <thead>+                <tr>+                    <th scope="col">Date</th>+                    <th scope="col">Notes</th>+                </tr>+            </thead>+            <tbody>+                {% for scrobble in scrobbles.all %}+                <tr>+                    <td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>+                    <td>{{scrobble.logdata.notes_as_str}}</td>+                </tr>+                {% endfor %}+            </tbody>+            </table>+        </div>+    </div>+</div>+{% endblock %}diff --git a/vrobbler/templates/discgolf/discgolfcourse_list.html b/vrobbler/templates/discgolf/discgolfcourse_list.htmlnew file mode 100644index 0000000..f7d5094--- /dev/null+++ b/vrobbler/templates/discgolf/discgolfcourse_list.html@@ -0,0 +1,13 @@+{% extends "base_list.html" %}++{% block title %}Disc Golf Rounds{% 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/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.htmlindex e35a889..a6ee919 100644--- a/vrobbler/templates/scrobbles/_last_scrobbles.html+++ b/vrobbler/templates/scrobbles/_last_scrobbles.html@@ -81,6 +81,15 @@         <p>No trails hiked today</p>         {% endif %} +        <h3><a href="{% url 'discgolf:course_list' %}">Disc Golf</a></h3>+        {% if DiscGolf %}+        {% with scrobbles=DiscGolf count=DiscGolf_count time=DiscGolf_time %}+        {% include "scrobbles/_scrobble_table.html" %}+        {% endwith %}+        {% else %}+        <p>No courses played today</p>+        {% endif %}+     </div>     <div class="col-md"> diff --git a/vrobbler/urls.py b/vrobbler/urls.pyindex 6e0418d..baf15c0 100644--- a/vrobbler/urls.py+++ b/vrobbler/urls.py@@ -7,6 +7,9 @@ from rest_framework import routers  import vrobbler.apps.scrobbles.views as scrobbles_views +from vrobbler.apps.discgolf import urls as discgolf_urls+from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet+ from vrobbler.apps.boardgames import urls as boardgame_urls from vrobbler.apps.boardgames.api.views import (     BoardGameViewSet,@@ -153,7 +156,7 @@ router.register(r"lifeevents", LifeEventViewSet)  router.register(r"birds", BirdViewSet) router.register(r"birding-locations", BirdingLocationViewSet)-+router.register(r"disc-golf-courses", DiscGolfCourseViewSet) urlpatterns = [     path("api/v1/", include(router.urls)),     path("api/v1/auth", include("rest_framework.urls")),@@ -171,6 +174,7 @@ urlpatterns = [     path("", include(locations_urls, namespace="locations")),     path("", include(trails_urls, namespace="trails")),     path("", include(beers_urls, namespace="beers")),+    path("", include(discgolf_urls, namespace="discgolf")),     path("", include(foods_urls, namespace="foods")),     path("", include(puzzles_urls, namespace="puzzles")),     path("", include(tasks_urls, namespace="tasks")),