This commit has no recorded session.
diff
commit 1f5fada8b1096e0282cf606610752345f7cddde8Author: Colin Powell <colin@unbl.ink>Date: Sat Jun 20 00:37:18 2026 -0400 [discgolf] Add new scrobble type@@ -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:@@ -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,+ ]@@ -0,0 +1,11 @@+from discgolf import models+from rest_framework import serializers+++class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):+ class Meta:+ model = models.DiscGolfCourse+ fields = "__all__"+++@@ -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]+++@@ -0,0 +1,6 @@+from django.apps import AppConfig+++class DiscgolfConfig(AppConfig):+ default_auto_field = "django.db.models.BigAutoField"+ name = "discgolf"@@ -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,+ },+ ),+ ]@@ -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",+ ),+ ),+ ]@@ -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),+ ),+ ]@@ -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"+ )@@ -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",+ ),+]@@ -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@@ -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@@ -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", )@@ -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", } @@ -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@@ -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",+ },+ ),+ ]@@ -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)@@ -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)@@ -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")@@ -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(),@@ -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))@@ -228,6 +228,7 @@ INSTALLED_APPS = [ "foods", "lifeevents", "moods",+ "discgolf", "birds", "mathfilters", "rest_framework",@@ -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 %}@@ -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 %}@@ -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"> @@ -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")),