From 1f5fada8b1096e0282cf606610752345f7cddde8 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sat, 20 Jun 2026 00:37:18 -0400 Subject: [PATCH] [discgolf] Add new scrobble type --- PROJECT.org | 42 ++++- vrobbler/apps/discgolf/__init__.py | 0 vrobbler/apps/discgolf/admin.py | 14 ++ vrobbler/apps/discgolf/api/__init__.py | 0 vrobbler/apps/discgolf/api/serializers.py | 11 ++ vrobbler/apps/discgolf/api/views.py | 13 ++ vrobbler/apps/discgolf/apps.py | 6 + vrobbler/apps/discgolf/management/__init__.py | 0 .../discgolf/management/commands/__init__.py | 0 .../apps/discgolf/migrations/0001_initial.py | 83 ++++++++++ .../migrations/0002_discgolfcourse_trail.py | 25 +++ .../0003_discgolfcourse_par_per_hole.py | 18 ++ vrobbler/apps/discgolf/migrations/__init__.py | 0 vrobbler/apps/discgolf/models.py | 76 +++++++++ vrobbler/apps/discgolf/urls.py | 14 ++ vrobbler/apps/discgolf/utils.py | 126 ++++++++++++++ vrobbler/apps/discgolf/views.py | 15 ++ vrobbler/apps/scrobbles/admin.py | 7 + vrobbler/apps/scrobbles/constants.py | 2 + vrobbler/apps/scrobbles/importers/webdav.py | 123 +++++++++++++- ...a_disc_golf_scrobble_disc_golf_and_more.py | 156 ++++++++++++++++++ vrobbler/apps/scrobbles/models.py | 70 +++++++- vrobbler/apps/scrobbles/scrobblers.py | 30 ++++ vrobbler/apps/scrobbles/tasks.py | 10 ++ vrobbler/apps/scrobbles/urls.py | 5 + vrobbler/apps/scrobbles/views.py | 8 + vrobbler/settings.py | 1 + .../discgolf/discgolfcourse_detail.html | 44 +++++ .../discgolf/discgolfcourse_list.html | 13 ++ .../templates/scrobbles/_last_scrobbles.html | 9 + vrobbler/urls.py | 6 +- 31 files changed, 922 insertions(+), 5 deletions(-) create mode 100644 vrobbler/apps/discgolf/__init__.py create mode 100644 vrobbler/apps/discgolf/admin.py create mode 100644 vrobbler/apps/discgolf/api/__init__.py create mode 100644 vrobbler/apps/discgolf/api/serializers.py create mode 100644 vrobbler/apps/discgolf/api/views.py create mode 100644 vrobbler/apps/discgolf/apps.py create mode 100644 vrobbler/apps/discgolf/management/__init__.py create mode 100644 vrobbler/apps/discgolf/management/commands/__init__.py create mode 100644 vrobbler/apps/discgolf/migrations/0001_initial.py create mode 100644 vrobbler/apps/discgolf/migrations/0002_discgolfcourse_trail.py create mode 100644 vrobbler/apps/discgolf/migrations/0003_discgolfcourse_par_per_hole.py create mode 100644 vrobbler/apps/discgolf/migrations/__init__.py create mode 100644 vrobbler/apps/discgolf/models.py create mode 100644 vrobbler/apps/discgolf/urls.py create mode 100644 vrobbler/apps/discgolf/utils.py create mode 100644 vrobbler/apps/discgolf/views.py create mode 100644 vrobbler/apps/scrobbles/migrations/0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more.py create mode 100644 vrobbler/templates/discgolf/discgolfcourse_detail.html create mode 100644 vrobbler/templates/discgolf/discgolfcourse_list.html diff --git a/PROJECT.org b/PROJECT.org index 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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/discgolf/admin.py b/vrobbler/apps/discgolf/admin.py new file mode 100644 index 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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/discgolf/api/serializers.py b/vrobbler/apps/discgolf/api/serializers.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/discgolf/management/commands/__init__.py b/vrobbler/apps/discgolf/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/discgolf/migrations/0001_initial.py b/vrobbler/apps/discgolf/migrations/0001_initial.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/discgolf/models.py b/vrobbler/apps/discgolf/models.py new file mode 100644 index 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.py new file mode 100644 index 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//", + views.DiscGolfCourseDetailView.as_view(), + name="course_detail", + ), +] diff --git a/vrobbler/apps/discgolf/utils.py b/vrobbler/apps/discgolf/utils.py new file mode 100644 index 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_scrobbles diff --git a/vrobbler/apps/discgolf/views.py b/vrobbler/apps/discgolf/views.py new file mode 100644 index 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 = DiscGolfCourse diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 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.py index 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.py index 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_imports diff --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.py new file mode 100644 index 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.py index 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.py index 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.py index 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.py index 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//", + 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.py index 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.py index 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.html new file mode 100644 index 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 %} +
+
+

{{object.description}}

+
+
+{% endif %} +{% if charts %} +
+
+ {% include "scrobbles/_chart_links.html" %} +
+
+{% endif %} +
+
+

Last scrobbles

+
+ + + + + + + + + {% for scrobble in scrobbles.all %} + + + + + {% endfor %} + +
DateNotes
{{scrobble.local_timestamp}}{{scrobble.logdata.notes_as_str}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/discgolf/discgolfcourse_list.html b/vrobbler/templates/discgolf/discgolfcourse_list.html new file mode 100644 index 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 %} +
+
+
+ {% include "_scrobblable_list.html" %} +
+
+
+{% endblock %} diff --git a/vrobbler/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.html index e35a889..a6ee919 100644 --- a/vrobbler/templates/scrobbles/_last_scrobbles.html +++ b/vrobbler/templates/scrobbles/_last_scrobbles.html @@ -81,6 +81,15 @@

No trails hiked today

{% endif %} +

Disc Golf

+ {% if DiscGolf %} + {% with scrobbles=DiscGolf count=DiscGolf_count time=DiscGolf_time %} + {% include "scrobbles/_scrobble_table.html" %} + {% endwith %} + {% else %} +

No courses played today

+ {% endif %} +
diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 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")),