sessions / 7f3076608f519b3bdf8823637cf8b71c446914ee

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

This commit has no recorded session.

diff

ignore whitespace

commit 7f3076608f519b3bdf8823637cf8b71c446914eeAuthor: Colin Powell <colin@unbl.ink>Date:   Tue Jun 9 12:33:25 2026 -0400    [scrobbles] Add sharing of scrobblesdiff --git a/PROJECT.org b/PROJECT.orgindex 6790814..6a957a2 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/14] :vrobbler:project:personal:+* Backlog [1/15] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: :PROPERTIES: :ID:       702462cf-d54b-48c6-8a7c-78b8de751deb@@ -535,6 +535,17 @@ to go try to enrich the media instance.  Should this enrichment fail, tag the sc log a warning and move on.           +** DONE [#A] Lock down scrobbles and use sqids to share them :feature:sharing:scrobbles:+:PROPERTIES:+:ID:       a6e869f7-8012-7e83-8f68-d0a0ed4c3c6a+:END:++*** Description++Currently all scrobbles are public. Anyone with the uuid can view any other+scrobbles. We should use SQIDs to allow shareable links to scrobbles and then+make all scrobbles hidden by default.+ * Version 48.1 [2/2] ** DONE [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata: :PROPERTIES:diff --git a/vrobbler/apps/profiles/migrations/0036_userprofile_default_scrobble_visibility.py b/vrobbler/apps/profiles/migrations/0036_userprofile_default_scrobble_visibility.pynew file mode 100644index 0000000..ab8e2c4--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0036_userprofile_default_scrobble_visibility.py@@ -0,0 +1,26 @@+# Generated by Django 4.2.29 on 2026-06-09 15:52++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("profiles", "0035_userprofile_monthly_mopidy_playlist_pattern"),+    ]++    operations = [+        migrations.AddField(+            model_name="userprofile",+            name="default_scrobble_visibility",+            field=models.CharField(+                choices=[+                    ("public", "Public"),+                    ("shared", "Shared"),+                    ("private", "Private"),+                ],+                default="shared",+                max_length=10,+            ),+        ),+    ]diff --git a/vrobbler/apps/profiles/migrations/0037_alter_userprofile_default_scrobble_visibility.py b/vrobbler/apps/profiles/migrations/0037_alter_userprofile_default_scrobble_visibility.pynew file mode 100644index 0000000..78e7e80--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0037_alter_userprofile_default_scrobble_visibility.py@@ -0,0 +1,26 @@+# Generated by Django 4.2.29 on 2026-06-09 16:25++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("profiles", "0036_userprofile_default_scrobble_visibility"),+    ]++    operations = [+        migrations.AlterField(+            model_name="userprofile",+            name="default_scrobble_visibility",+            field=models.CharField(+                choices=[+                    ("public", "Public"),+                    ("shared", "Shared"),+                    ("private", "Private"),+                ],+                default="private",+                max_length=10,+            ),+        ),+    ]diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.pyindex c4fad55..25df542 100644--- a/vrobbler/apps/profiles/models.py+++ b/vrobbler/apps/profiles/models.py@@ -9,6 +9,11 @@ from django.utils.functional import cached_property from django_extensions.db.models import TimeStampedModel from encrypted_field import EncryptedField from profiles.constants import PRETTY_TIMEZONE_CHOICES+VISIBILITY_CHOICES = (+    ("public", "Public"),+    ("shared", "Shared"),+    ("private", "Private"),+)  User = get_user_model() BNULL = {"blank": True, "null": True}@@ -79,6 +84,12 @@ class UserProfile(TimeStampedModel):     enable_public_widgets = models.BooleanField(default=False)     widget_custom_css = models.TextField(**BNULL) +    default_scrobble_visibility = models.CharField(+        max_length=10,+        choices=VISIBILITY_CHOICES,+        default="private",+    )+     home_scrobble_limit = models.IntegerField(default=20)      weigh_in_units = models.CharField(diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.pyindex bd75925..10aae54 100644--- a/vrobbler/apps/scrobbles/admin.py+++ b/vrobbler/apps/scrobbles/admin.py@@ -10,6 +10,7 @@ from scrobbles.models import (     RetroarchImport,     ScaleCSVImport,     Scrobble,+    ShareViewLog,     TrailGPXImport, ) from scrobbles.mixins import Genre@@ -116,6 +117,7 @@ class ScrobbleAdmin(admin.ModelAdmin):         "in_progress",         "is_paused",         "played_to_completion",+        "visibility",         "user",     )     raw_id_fields = (@@ -143,6 +145,7 @@ class ScrobbleAdmin(admin.ModelAdmin):         "is_paused",         "in_progress",         "media_type",+        "visibility",         "long_play_complete",         "source",         "timezone",@@ -161,6 +164,14 @@ class ScrobbleAdmin(admin.ModelAdmin):         return qs  +@admin.register(ShareViewLog)+class ShareViewLogAdmin(admin.ModelAdmin):+    list_display = ("scrobble", "ip_address", "created")+    list_filter = ("created",)+    date_hierarchy = "created"+    raw_id_fields = ("scrobble",)++ @admin.register(FavoriteMedia) class FavoriteMediaAdmin(admin.ModelAdmin):     list_display = ("user", "media_type", "sent_to_mopidy", "created")diff --git a/vrobbler/apps/scrobbles/api/serializers.py b/vrobbler/apps/scrobbles/api/serializers.pyindex 109bd89..b44a9d3 100644--- a/vrobbler/apps/scrobbles/api/serializers.py+++ b/vrobbler/apps/scrobbles/api/serializers.py@@ -1,6 +1,7 @@ import re  from rest_framework import serializers+from scrobbles.constants import Visibility from scrobbles.models import (     AudioScrobblerTSVImport,     KoReaderImport,diff --git a/vrobbler/apps/scrobbles/api/views.py b/vrobbler/apps/scrobbles/api/views.pyindex 320b7cf..d000cf1 100644--- a/vrobbler/apps/scrobbles/api/views.py+++ b/vrobbler/apps/scrobbles/api/views.py@@ -1,6 +1,8 @@ from logging import getLogger  from rest_framework import permissions, viewsets+from rest_framework.decorators import action+from rest_framework.response import Response from scrobbles.api.serializers import (     AudioScrobblerTSVImportSerializer,     KoReaderImportSerializer,@@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):     def get_queryset(self):         return super().get_queryset().filter(user=self.request.user) +    @action(detail=True, methods=["post"])+    def regenerate_share_token(self, request, uuid=None):+        scrobble = self.get_object()+        scrobble.regenerate_share_token()+        return Response({"share_url": scrobble.get_share_url()})+  class KoReaderImportViewSet(viewsets.ModelViewSet):     queryset = KoReaderImport.objects.all().order_by("-created")diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex 7f5b343..877ad73 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -1,6 +1,12 @@+from django.db import models from enum import Enum  JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]++class Visibility(models.TextChoices):+    PUBLIC = "public", "Public"+    SHARED = "shared", "Shared"+    PRIVATE = "private", "Private" JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]  LONG_PLAY_MEDIA = {diff --git a/vrobbler/apps/scrobbles/migrations/0091_scrobble_share_token_scrobble_visibility.py b/vrobbler/apps/scrobbles/migrations/0091_scrobble_share_token_scrobble_visibility.pynew file mode 100644index 0000000..925c5ed--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0091_scrobble_share_token_scrobble_visibility.py@@ -0,0 +1,32 @@+# Generated by Django 4.2.29 on 2026-06-09 15:52++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("scrobbles", "0090_audioscrobblertsvimport_error_log_and_more"),+    ]++    operations = [+        migrations.AddField(+            model_name="scrobble",+            name="share_token",+            field=models.UUIDField(blank=True, editable=False, null=True, unique=True),+        ),+        migrations.AddField(+            model_name="scrobble",+            name="visibility",+            field=models.CharField(+                choices=[+                    ("public", "Public"),+                    ("shared", "Shared"),+                    ("private", "Private"),+                ],+                db_index=True,+                default="shared",+                max_length=10,+            ),+        ),+    ]diff --git a/vrobbler/apps/scrobbles/migrations/0092_backfill_visibility_and_share_token.py b/vrobbler/apps/scrobbles/migrations/0092_backfill_visibility_and_share_token.pynew file mode 100644index 0000000..c3c1f79--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0092_backfill_visibility_and_share_token.py@@ -0,0 +1,29 @@+from uuid import uuid4++from django.db import migrations+++def backfill_share_token(apps, schema_editor):+    Scrobble = apps.get_model("scrobbles", "Scrobble")+    batch = []+    for scrobble in Scrobble.objects.filter(share_token__isnull=True).iterator(+        chunk_size=500+    ):+        scrobble.share_token = uuid4()+        batch.append(scrobble)+    if batch:+        Scrobble.objects.bulk_update(batch, ["share_token"], batch_size=500)+++class Migration(migrations.Migration):++    dependencies = [+        ("scrobbles", "0091_scrobble_share_token_scrobble_visibility"),+    ]++    operations = [+        migrations.RunPython(+            backfill_share_token,+            reverse_code=migrations.RunPython.noop,+        ),+    ]diff --git a/vrobbler/apps/scrobbles/migrations/0093_remove_scrobble_share_token_and_more.py b/vrobbler/apps/scrobbles/migrations/0093_remove_scrobble_share_token_and_more.pynew file mode 100644index 0000000..12cd365--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0093_remove_scrobble_share_token_and_more.py@@ -0,0 +1,22 @@+# Generated by Django 4.2.29 on 2026-06-09 16:05++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("scrobbles", "0092_backfill_visibility_and_share_token"),+    ]++    operations = [+        migrations.RemoveField(+            model_name="scrobble",+            name="share_token",+        ),+        migrations.AddField(+            model_name="scrobble",+            name="share_token_version",+            field=models.PositiveIntegerField(default=0),+        ),+    ]diff --git a/vrobbler/apps/scrobbles/migrations/0094_scrobble_share_view_count_alter_scrobble_visibility_and_more.py b/vrobbler/apps/scrobbles/migrations/0094_scrobble_share_view_count_alter_scrobble_visibility_and_more.pynew file mode 100644index 0000000..398b1ca--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0094_scrobble_share_view_count_alter_scrobble_visibility_and_more.py@@ -0,0 +1,75 @@+# Generated by Django 4.2.29 on 2026-06-09 16:24++from django.db import migrations, models+import django.db.models.deletion+import django_extensions.db.fields+++class Migration(migrations.Migration):++    dependencies = [+        ("scrobbles", "0093_remove_scrobble_share_token_and_more"),+    ]++    operations = [+        migrations.AddField(+            model_name="scrobble",+            name="share_view_count",+            field=models.PositiveIntegerField(default=0),+        ),+        migrations.AlterField(+            model_name="scrobble",+            name="visibility",+            field=models.CharField(+                choices=[+                    ("public", "Public"),+                    ("shared", "Shared"),+                    ("private", "Private"),+                ],+                db_index=True,+                default="private",+                max_length=10,+            ),+        ),+        migrations.CreateModel(+            name="ShareViewLog",+            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"+                    ),+                ),+                ("ip_address", models.GenericIPAddressField(blank=True, null=True)),+                ("user_agent", models.TextField(blank=True, null=True)),+                ("referrer", models.URLField(blank=True, max_length=2048, null=True)),+                (+                    "scrobble",+                    models.ForeignKey(+                        on_delete=django.db.models.deletion.CASCADE,+                        related_name="share_views",+                        to="scrobbles.scrobble",+                    ),+                ),+            ],+            options={+                "get_latest_by": "modified",+                "abstract": False,+            },+        ),+    ]diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 7041f96..fd65440 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -18,6 +18,8 @@ from bricksets.models import BrickSet from charts.utils import build_charts from dataclass_wizard.errors import ParseError from django.conf import settings+from scrobbles.constants import Visibility+from scrobbles.sqids import encode_scrobble_share from django.contrib.auth import get_user_model from django.core.files import File from django.db import models@@ -633,6 +635,18 @@ class ScrobbleQuerySet(models.QuerySet):         )  +class ShareViewLog(TimeStampedModel):+    scrobble = models.ForeignKey(+        "Scrobble", on_delete=models.CASCADE, related_name="share_views"+    )+    ip_address = models.GenericIPAddressField(**BNULL)+    user_agent = models.TextField(**BNULL)+    referrer = models.URLField(max_length=2048, **BNULL)++    def __str__(self):+        return f"View of {self.scrobble} at {self.created}"++ class Scrobble(TimeStampedModel):     """A scrobble tracks played media items by a user.""" @@ -694,6 +708,14 @@ class Scrobble(TimeStampedModel):     media_type = models.CharField(         max_length=20, choices=MediaType.choices, default=MediaType.VIDEO     )+    visibility = models.CharField(+        max_length=10,+        choices=Visibility.choices,+        default=Visibility.PRIVATE,+        db_index=True,+    )+    share_token_version = models.PositiveIntegerField(default=0)+    share_view_count = models.PositiveIntegerField(default=0)     user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)      # Time keeping@@ -875,6 +897,16 @@ class Scrobble(TimeStampedModel):             self.save(update_fields=["uuid"])         return reverse("scrobbles:detail", kwargs={"uuid": self.uuid}) +    def get_share_url(self):+        if self.visibility == Visibility.PRIVATE:+            return None+        sqid = encode_scrobble_share(self.id, self.share_token_version)+        return reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})++    def regenerate_share_token(self):+        self.share_token_version += 1+        self.save(update_fields=["share_token_version"])+     def push_to_archivebox(self):         pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(             self.media_obj.push_to_archiveboxdiff --git a/vrobbler/apps/scrobbles/sqids.py b/vrobbler/apps/scrobbles/sqids.pynew file mode 100644index 0000000..aafc828--- /dev/null+++ b/vrobbler/apps/scrobbles/sqids.py@@ -0,0 +1,36 @@+from sqids import Sqids++_sqids = None+++def _make_alphabet() -> str:+    import hashlib+    from django.conf import settings++    digest = hashlib.sha256(settings.SECRET_KEY.encode()).hexdigest()+    base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"+    seed = int(digest[:16], 16)+    shuffled = list(base)+    for i in range(len(shuffled) - 1, 0, -1):+        seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF+        j = seed % (i + 1)+        shuffled[i], shuffled[j] = shuffled[j], shuffled[i]+    return "".join(shuffled)+++def get_sqids() -> Sqids:+    global _sqids+    if _sqids is None:+        _sqids = Sqids(+            alphabet=_make_alphabet(),+            min_length=6,+        )+    return _sqids+++def encode_scrobble_share(scrobble_id: int, version: int) -> str:+    return get_sqids().encode([scrobble_id, version])+++def decode_scrobble_share(sqid: str) -> list[int] | None:+    return get_sqids().decode(sqid)diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.pyindex a76a47d..6bc7ee3 100644--- a/vrobbler/apps/scrobbles/urls.py+++ b/vrobbler/apps/scrobbles/urls.py@@ -153,11 +153,32 @@ urlpatterns = [         name="long-plays",     ),     path("scrobbles/", views.ScrobbleListView.as_view(), name="scrobble-list"),+    path("explore/", views.ScrobbleExploreView.as_view(), name="explore"),+    path(+        "shared/<str:sqid>/",+        views.ScrobbleShareView.as_view(),+        name="shared-detail",+    ),     path(         "scrobbles/<slug:uuid>/",         views.ScrobbleDetailView.as_view(),         name="detail",     ),+    path(+        "scrobbles/<slug:uuid>/regenerate-share-token/",+        views.RegenerateShareTokenView.as_view(),+        name="regenerate-share-token",+    ),+    path(+        "scrobbles/<slug:uuid>/change-visibility/",+        views.ChangeVisibilityView.as_view(),+        name="change-visibility",+    ),+    path(+        "scrobbles/<slug:uuid>/share-analytics/",+        views.ScrobbleShareAnalyticsView.as_view(),+        name="share-analytics",+    ),     path(         "scrobbles/<slug:uuid>/add-to-mopidy-queue/",         views.add_to_mopidy_queue,diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex 28f5120..9ffa153 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -14,7 +14,7 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator-from django.db.models import Count, Max, Q, Sum+from django.db.models import Count, F, Max, Q, Sum from django.db.models.query import QuerySet from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token@@ -38,7 +38,7 @@ from django.utils import timezone from django.utils.dateformat import DateFormat from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST-from django.views.generic import DetailView, FormView, TemplateView+from django.views.generic import DetailView, FormView, TemplateView, View from django.views.generic.edit import CreateView from django.views.generic.list import ListView from moods.models import Mood@@ -72,6 +72,8 @@ from scrobbles.constants import ( ) from scrobbles.export import export_scrobbles from scrobbles.forms import ExportScrobbleForm, ScrobbleForm+from scrobbles.constants import Visibility+from scrobbles.sqids import decode_scrobble_share from scrobbles.models import (     AudioScrobblerTSVImport,     BGStatsImport,@@ -83,6 +85,7 @@ from scrobbles.models import (     ScaleCSVImport,     Scrobble,     ScrobbleQuerySet,+    ShareViewLog,     TrailGPXImport, ) from scrobbles.scrobblers import *@@ -1136,6 +1139,15 @@ class ScrobbleDetailView(DetailView):     slug_url_kwarg = "uuid"     paginate_by = 100 +    def get_object(self, queryset=None):+        scrobble = super().get_object(queryset=queryset)+        user = self.request.user+        if scrobble.visibility == Visibility.PUBLIC:+            return scrobble+        if user.is_authenticated and scrobble.user == user:+            return scrobble+        raise Http404+     def get_form_class(self):         return self.object.media_obj.logdata_cls().form() @@ -1252,6 +1264,93 @@ class ScrobbleDetailView(DetailView):         return context  +class ScrobbleShareView(TemplateView):+    template_name = "scrobbles/scrobble_share.html"++    def get_object(self):+        sqid = self.kwargs.get("sqid")+        decoded = decode_scrobble_share(sqid)+        if not decoded or len(decoded) != 2:+            raise Http404+        scrobble_id, version = decoded+        scrobble = get_object_or_404(Scrobble, id=scrobble_id)+        if scrobble.share_token_version != version:+            raise Http404+        if scrobble.visibility not in (Visibility.PUBLIC, Visibility.SHARED):+            raise Http404+        Scrobble.objects.filter(id=scrobble.id).update(+            share_view_count=F("share_view_count") + 1+        )+        scrobble.refresh_from_db(fields=["share_view_count"])+        ShareViewLog.objects.create(+            scrobble=scrobble,+            ip_address=self.request.META.get("REMOTE_ADDR"),+            user_agent=self.request.META.get("HTTP_USER_AGENT", "")[:500],+            referrer=self.request.META.get("HTTP_REFERER", ""),+        )+        return scrobble++    def get_context_data(self, **kwargs):+        context = super().get_context_data(**kwargs)+        scrobble = self.get_object()+        context["object"] = scrobble+        context["log_form"] = None+        context["related_scrobbles"] = Scrobble.objects.none()+        context["has_mopidy_uri"] = False+        if self.request.user.is_authenticated:+            media_type = scrobble.media_type+            fk_field = ScrobbleDetailView.MEDIA_FK_MAP.get(media_type)+            media_obj = scrobble.media_obj+            if fk_field and media_obj:+                context["is_favorited"] = FavoriteMedia.objects.filter(+                    user=self.request.user, **{fk_field: media_obj}+                ).exists()+        return context+++class ScrobbleExploreView(ListView):+    model = Scrobble+    paginate_by = 100+    template_name = "scrobbles/scrobble_explore.html"+    queryset = Scrobble.objects.filter(visibility=Visibility.PUBLIC).order_by(+        "-timestamp"+    )+++class RegenerateShareTokenView(LoginRequiredMixin, View):+    def post(self, request, uuid):+        scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)+        scrobble.regenerate_share_token()+        return redirect(scrobble.get_absolute_url())+++class ChangeVisibilityView(LoginRequiredMixin, View):+    def post(self, request, uuid):+        scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)+        visibility = request.POST.get("visibility")+        if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):+            return redirect(scrobble.get_absolute_url())+        scrobble.visibility = visibility+        scrobble.save(update_fields=["visibility"])+        return redirect(scrobble.get_absolute_url())+++class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):+    model = Scrobble+    slug_field = "uuid"+    slug_url_kwarg = "uuid"+    template_name = "scrobbles/scrobble_share_analytics.html"++    def get_queryset(self):+        return Scrobble.objects.filter(user=self.request.user)++    def get_context_data(self, **kwargs):+        context = super().get_context_data(**kwargs)+        scrobble = self.object+        context["share_views"] = scrobble.share_views.order_by("-created")[:50]+        return context++ class BaseEmbeddableWidget(TemplateView):     template_name = "scrobbles/embeddable_top_media.html" diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.htmlindex f1c6797..d11b273 100644--- a/vrobbler/templates/scrobbles/scrobble_detail.html+++ b/vrobbler/templates/scrobbles/scrobble_detail.html@@ -78,6 +78,40 @@ <h2>{{ object.logdata.title }}</h2> {% endif %} <h3 class="text-muted">{{ object.local_timestamp }}</h3>++{% if user.is_authenticated and object.user == user %}+<div class="mb-3 d-flex align-items-center gap-2 flex-wrap">+    <span class="badge +        {% if object.visibility == 'public' %}bg-success+        {% elif object.visibility == 'shared' %}bg-warning text-dark+        {% else %}bg-secondary+        {% endif %}">+        {{ object.get_visibility_display }}+    </span>+    <form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">+        {% csrf_token %}+        <select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">+            <option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>+            <option value="shared" {% if object.visibility == 'shared' %}selected{% endif %}>Shared (link)</option>+            <option value="public" {% if object.visibility == 'public' %}selected{% endif %}>Public (explore)</option>+        </select>+    </form>+    {% if object.visibility == 'shared' and object.get_share_url %}+    <span class="small text-muted">Share link:</span>+    <code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>+    <button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>+    <form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">+        {% csrf_token %}+        <button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>+    </form>+    {% if object.share_view_count %}+    <span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>+    {% endif %}+    <a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>+    {% endif %}+</div>+{% endif %}+ {% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %} <form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">   {% csrf_token %}diff --git a/vrobbler/templates/scrobbles/scrobble_explore.html b/vrobbler/templates/scrobbles/scrobble_explore.htmlnew file mode 100644index 0000000..fe978ae--- /dev/null+++ b/vrobbler/templates/scrobbles/scrobble_explore.html@@ -0,0 +1,140 @@+{% extends "base.html" %}+{% load humanize %}+{% load naturalduration %}++{% block content %}+<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">+    <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">+        <h1 class="h2">Explore Public Scrobbles</h1>+    </div>++    <p class="text-muted">Recent public scrobbles from all users.</p>++    <div class="table-responsive">+        <table class="table table-striped table-sm">+            <thead>+                <tr>+                    <th scope="col">Date</th>+                    <th scope="col">Type</th>+                    <th scope="col">Title</th>+                    <th scope="col">User</th>+                    <th scope="col">Time</th>+                </tr>+            </thead>+            <tbody>+                {% for scrobble in object_list %}+                <tr>+                    <td>+                        <a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>+                    </td>+                    <td>+                        {% if scrobble.video %}+                        ๐ŸŽฌ Video+                        {% elif scrobble.track %}+                        ๐ŸŽต Track+                        {% elif scrobble.podcast_episode %}+                        ๐ŸŽ™๏ธ Podcast episode+                        {% elif scrobble.sport_event %}+                        โšฝ Sport event+                        {% elif scrobble.book %}+                        ๐Ÿ“– Book+                        {% elif scrobble.paper %}+                        ๐Ÿ“„ Paper+                        {% elif scrobble.video_game %}+                        ๐ŸŽฎ Video game+                        {% elif scrobble.board_game %}+                        ๐ŸŽฒ Board game+                        {% elif scrobble.geo_location %}+                        ๐Ÿ“ GeoLocation+                        {% elif scrobble.trail %}+                        ๐Ÿฅพ Trail+                        {% elif scrobble.beer %}+                        ๐Ÿบ Beer+                        {% elif scrobble.puzzle %}+                        ๐Ÿงฉ Puzzle+                        {% elif scrobble.food %}+                        ๐Ÿ” Food+                        {% elif scrobble.task %}+                        โœ… Task+                        {% elif scrobble.web_page %}+                        ๐ŸŒ Web Page+                        {% elif scrobble.life_event %}+                        ๐ŸŽ‰ Life event+                        {% elif scrobble.mood %}+                        ๐Ÿ˜Š Mood+                        {% elif scrobble.brick_set %}+                        ๐Ÿงฑ Brick set+                        {% elif scrobble.channel %}+                        ๐Ÿ“บ Channel+                        {% else %}+                        Unknown+                        {% endif %}+                    </td>+                    <td>+                        {% if scrobble.video %}+                        <a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>+                        {% elif scrobble.track %}+                        <a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>+                        {% elif scrobble.video_game %}+                        <a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>+                        {% elif scrobble.book %}+                        <a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>+                        {% elif scrobble.food %}+                        <a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>+                        {% elif scrobble.beer %}+                        <a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>+                        {% elif scrobble.web_page %}+                        <a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>+                        {% elif scrobble.podcast_episode %}+                        <a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>+                        {% elif scrobble.board_game %}+                        <a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>+                        {% elif scrobble.trail %}+                        <a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>+                        {% elif scrobble.puzzle %}+                        <a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>+                        {% elif scrobble.brick_set %}+                        <a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>+                        {% elif scrobble.task %}+                        <a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title  %} - {{ scrobble.log.title }}{% endif %}</a>+                        {% elif scrobble.life_event %}+                        <a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>+                        {% elif scrobble.mood %}+                        <a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>+                        {% elif scrobble.geo_location %}+                        <a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>+                        {% else %}+                        Unknown+                        {% endif %}+                    </td>+                    <td>{{ scrobble.user.username }}</td>+                    <td>+                        {% if scrobble.playback_position_seconds %}+                        {{ scrobble.playback_position_seconds|natural_duration }}+                        {% endif %}+                    </td>+                </tr>+                {% empty %}+                <tr>+                    <td colspan="5">No public scrobbles found.</td>+                </tr>+                {% endfor %}+            </tbody>+        </table>+    </div>++    {% if page_obj.has_previous or page_obj.has_next %}+    <nav>+        <ul class="pagination">+            {% if page_obj.has_previous %}+            <li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>+            {% endif %}+            <li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>+            {% if page_obj.has_next %}+            <li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>+            {% endif %}+        </ul>+    </nav>+    {% endif %}+</main>+{% endblock %}diff --git a/vrobbler/templates/scrobbles/scrobble_share.html b/vrobbler/templates/scrobbles/scrobble_share.htmlnew file mode 100644index 0000000..3f6318d--- /dev/null+++ b/vrobbler/templates/scrobbles/scrobble_share.html@@ -0,0 +1,200 @@+{% extends "base_list.html" %}+{% load form_tags %}+{% load mathfilters %}+{% load naturalduration %}+{% load static %}++{% block title %}{{object.name}}{% endblock %}++{% block head_extra %}+    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />+    <style>+        dl { border:none; }+        dt {+            background: #333;+            color: #fff;+        }+        dd {+            float:left;+            margin: 2px;+            padding: 4px;+            min-height: 1em;+            border: none;+        }+        #map {+            height: 400px;+            border-radius: 4px;+        }+    </style>+{% endblock %}++{% block lists %}++<div class="row">++<div class="alert alert-info">+    Shared via link+</div>++<h1 class="d-flex align-items-center gap-2">+{% if object.media_type == "Video" %}๐ŸŽฌ{% elif object.media_type == "Track" %}๐ŸŽต{% elif object.media_type == "PodcastEpisode" %}๐ŸŽ™๏ธ{% elif object.media_type == "SportEvent" %}โšฝ{% elif object.media_type == "Book" %}๐Ÿ“š{% elif object.media_type == "Paper" %}๐Ÿ“„{% elif object.media_type == "VideoGame" %}๐ŸŽฎ{% elif object.media_type == "BoardGame" %}๐ŸŽฒ{% elif object.media_type == "GeoLocation" %}๐Ÿ“{% elif object.media_type == "Trail" %}๐Ÿฅพ{% elif object.media_type == "Beer" %}๐Ÿบ{% elif object.media_type == "Puzzle" %}๐Ÿงฉ{% elif object.media_type == "Food" %}๐Ÿ”{% elif object.media_type == "Task" %}โœ…{% elif object.media_type == "WebPage" %}๐ŸŒ{% elif object.media_type == "LifeEvent" %}๐ŸŽ‰{% elif object.media_type == "Mood" %}๐Ÿ˜Š{% elif object.media_type == "BrickSet" %}๐Ÿงฑ{% elif object.media_type == "Channel" %}๐Ÿ“บ{% endif %}+{% if object.media_obj.get_absolute_url %}+    <a href="{{ object.media_obj.get_absolute_url }}">{% endif %}+        {{ object.media_obj.title }}+        {% if object.media_obj.get_absolute_url %}</a>+        {% endif %}+</h1>+<h2>{{ object.media_obj.subtitle }}</h2>+<p>+{% if object.media_type == "SportEvent" %}+{% for team in object.media_obj.teams.all %}+<img src="{{team.logo.url}}" width=150 />+{% endfor %}+{% endif %}+{% if object.media_type == "Task" and object.logdata.title %}+    </p>+<h2>{{ object.logdata.title }}</h2>+{% endif %}+<h3 class="text-muted">{{ object.local_timestamp }}</h3>+{% if object.media_type == "Track" %}+<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>+{% endif %}+{% if object.media_type == "Task" and object.log.weight %}+<div class="mb-3">+  <dl class="row" style="max-width: 400px;">+    <dt class="col-sm-5">Weight</dt>+    <dd class="col-sm-7">{{ object.log.weight }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>+    {% if object.log.body_fat %}+    <dt class="col-sm-5">Body Fat</dt>+    <dd class="col-sm-7">{{ object.log.body_fat }}%</dd>+    {% endif %}+    {% if object.log.bmi %}+    <dt class="col-sm-5">BMI</dt>+    <dd class="col-sm-7">{{ object.log.bmi }}</dd>+    {% endif %}+    {% if object.log.muscle %}+    <dt class="col-sm-5">Muscle</dt>+    <dd class="col-sm-7">{{ object.log.muscle }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>+    {% endif %}+    {% if object.log.bone %}+    <dt class="col-sm-5">Bone</dt>+    <dd class="col-sm-7">{{ object.log.bone }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>+    {% endif %}+    {% if object.log.water %}+    <dt class="col-sm-5">Water</dt>+    <dd class="col-sm-7">{{ object.log.water }}%</dd>+    {% endif %}+    {% if object.log.visceral_fat %}+    <dt class="col-sm-5">Visceral Fat</dt>+    <dd class="col-sm-7">{{ object.log.visceral_fat }}</dd>+    {% endif %}+    {% if object.log.waist %}+    <dt class="col-sm-5">Waist</dt>+    <dd class="col-sm-7">{{ object.log.waist }} {% if object.log.unit_type == "imperial" %}in{% else %}cm{% endif %}</dd>+    {% endif %}+    {% if object.log.lbm %}+    <dt class="col-sm-5">Lean Mass</dt>+    <dd class="col-sm-7">{{ object.log.lbm }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>+    {% endif %}+    {% if object.log.calories %}+    <dt class="col-sm-5">Calories</dt>+    <dd class="col-sm-7">{{ object.log.calories }}</dd>+    {% endif %}+    {% if object.log.comment %}+    <dt class="col-sm-5">Comment</dt>+    <dd class="col-sm-7">{{ object.log.comment }}</dd>+    {% endif %}+  </dl>+</div>+{% endif %}+{% if object.media_type == "Task" and object.logdata.description %}+<p>{{ object.logdata.description }}</p>+{% endif %}++{% if object.media_type == "Trail" and object.gpx_file %}+<div class="mb-3">+    <div id="map"></div>+</div>+{% endif %}++<p>+    Tags: +    {% if object.tags.all %}+    {% for tag in object.tags.all %}+        <span class="badge bg-secondary">{{ tag.name }}</span>+    {% endfor %}+    {% else %}+    untagged+    {% endif %}+</p>++{% with notes_html=object.logdata.notes_as_html %}+{% if notes_html %}+<div class="mb-3">+  <h4>Notes</h4>+  <span class="badge fs-8+      {% if sentiment.compound >= 0.5 %}bg-success+      {% elif sentiment.compound >= 0.05 %}bg-info text-dark+      {% elif sentiment.compound > -0.05 %}bg-secondary+      {% elif sentiment.compound > -0.5 %}bg-warning text-dark+      {% else %}bg-danger+      {% endif %}">+      {% if sentiment.compound >= 0.5 %}Positive+      {% elif sentiment.compound >= 0.05 %}Slightly positive+      {% elif sentiment.compound > -0.05 %}Neutral+      {% elif sentiment.compound > -0.5 %}Slightly negative+      {% else %}Negative+      {% endif %}+  </span>+  <div class="notes-list">+    {{ notes_html|safe }}+  </div>+</div>+{% endif %}+{% endwith %}++{% with sentiment=object.log.sentiment %}+{% if sentiment %}+<div class="mb-3">+</div>+{% endif %}+{% endwith %}++{% if object.logdata.avg_seconds_per_page %}+<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>+{% endif %}++{% if object.media_type == "BoardGame" and object.logdata.as_html %}+<div class="mb-3">+  <h5>Game Details</h5>+  {{ object.logdata.as_html|safe }}+</div>+{% endif %}++</div>++{% endblock %}++{% block extra_js %}+{{ block.super }}+{% if object.media_type == "Trail" and object.gpx_file %}+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>+<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>+<script>+var map = L.map('map');+L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {+    maxZoom: 19,+    attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',+    referrerPolicy: 'origin'+}).addTo(map);+var gpx = new L.GPX("{{ object.gpx_file.url|escapejs }}", {+    async: true,+    polyline_options: { color: '#e74c3c' }+});+gpx.on('loaded', function(e) {+    map.fitBounds(e.target.getBounds());+});+gpx.addTo(map);+</script>+{% endif %}+{% endblock %}diff --git a/vrobbler/templates/scrobbles/scrobble_share_analytics.html b/vrobbler/templates/scrobbles/scrobble_share_analytics.htmlnew file mode 100644index 0000000..47d2b32--- /dev/null+++ b/vrobbler/templates/scrobbles/scrobble_share_analytics.html@@ -0,0 +1,68 @@+{% extends "base_list.html" %}+{% load naturalduration %}++{% block title %}Share Analytics for {{ object.media_obj.title }}{% endblock %}++{% block lists %}++<div class="row">++<h1>Share Analytics</h1>+<h2 class="text-muted">{{ object.media_obj.title }}</h2>++<div class="mb-3">+    <span class="badge +        {% if object.visibility == 'public' %}bg-success+        {% elif object.visibility == 'shared' %}bg-warning text-dark+        {% else %}bg-secondary+        {% endif %}">+        {{ object.get_visibility_display }}+    </span>+    <span class="ms-2"><strong>{{ object.share_view_count }}</strong> view{{ object.share_view_count|pluralize }}</span>+</div>++{% if object.get_share_url %}+<div class="mb-3">+    <span class="text-muted">Share link:</span>+    <code class="small">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>+</div>+{% endif %}++<h3 class="mt-4">View History</h3>++{% if share_views %}+<table class="table table-striped table-sm">+    <thead>+        <tr>+            <th scope="col">Time</th>+            <th scope="col">IP Address</th>+            <th scope="col">Referrer</th>+            <th scope="col">User Agent</th>+        </tr>+    </thead>+    <tbody>+        {% for view in share_views %}+        <tr>+            <td>{{ view.created|date:"M d, Y H:i" }}</td>+            <td><code>{{ view.ip_address|default:"-" }}</code></td>+            <td class="text-truncate" style="max-width: 200px;">+                {% if view.referrer %}+                <a href="{{ view.referrer }}" target="_blank" rel="noopener">{{ view.referrer }}</a>+                {% else %}-{% endif %}+            </td>+            <td class="text-truncate" style="max-width: 300px;">+                <span title="{{ view.user_agent }}">{{ view.user_agent|default:"-"|truncatechars:60 }}</span>+            </td>+        </tr>+        {% endfor %}+    </tbody>+</table>+{% else %}+<p class="text-muted">No views yet. Share the link to see who visits.</p>+{% endif %}++<a href="{{ object.get_absolute_url }}" class="btn btn-secondary">Back to scrobble</a>++</div>++{% endblock %}