From 7f3076608f519b3bdf8823637cf8b71c446914ee Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Tue, 9 Jun 2026 12:33:25 -0400 Subject: [PATCH] [scrobbles] Add sharing of scrobbles --- PROJECT.org | 13 +- ...userprofile_default_scrobble_visibility.py | 26 +++ ...userprofile_default_scrobble_visibility.py | 26 +++ vrobbler/apps/profiles/models.py | 11 + vrobbler/apps/scrobbles/admin.py | 11 + vrobbler/apps/scrobbles/api/serializers.py | 1 + vrobbler/apps/scrobbles/api/views.py | 8 + vrobbler/apps/scrobbles/constants.py | 6 + ...crobble_share_token_scrobble_visibility.py | 32 +++ ...092_backfill_visibility_and_share_token.py | 29 +++ ...93_remove_scrobble_share_token_and_more.py | 22 ++ ...ount_alter_scrobble_visibility_and_more.py | 75 +++++++ vrobbler/apps/scrobbles/models.py | 32 +++ vrobbler/apps/scrobbles/sqids.py | 36 ++++ vrobbler/apps/scrobbles/urls.py | 21 ++ vrobbler/apps/scrobbles/views.py | 103 ++++++++- .../templates/scrobbles/scrobble_detail.html | 34 +++ .../templates/scrobbles/scrobble_explore.html | 140 ++++++++++++ .../templates/scrobbles/scrobble_share.html | 200 ++++++++++++++++++ .../scrobbles/scrobble_share_analytics.html | 68 ++++++ 20 files changed, 891 insertions(+), 3 deletions(-) create mode 100644 vrobbler/apps/profiles/migrations/0036_userprofile_default_scrobble_visibility.py create mode 100644 vrobbler/apps/profiles/migrations/0037_alter_userprofile_default_scrobble_visibility.py create mode 100644 vrobbler/apps/scrobbles/migrations/0091_scrobble_share_token_scrobble_visibility.py create mode 100644 vrobbler/apps/scrobbles/migrations/0092_backfill_visibility_and_share_token.py create mode 100644 vrobbler/apps/scrobbles/migrations/0093_remove_scrobble_share_token_and_more.py create mode 100644 vrobbler/apps/scrobbles/migrations/0094_scrobble_share_view_count_alter_scrobble_visibility_and_more.py create mode 100644 vrobbler/apps/scrobbles/sqids.py create mode 100644 vrobbler/templates/scrobbles/scrobble_explore.html create mode 100644 vrobbler/templates/scrobbles/scrobble_share.html create mode 100644 vrobbler/templates/scrobbles/scrobble_share_analytics.html diff --git a/PROJECT.org b/PROJECT.org index 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.py new file mode 100644 index 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.py new file mode 100644 index 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.py index 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.py index 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.py index 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.py index 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.py index 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.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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.py index 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_archivebox diff --git a/vrobbler/apps/scrobbles/sqids.py b/vrobbler/apps/scrobbles/sqids.py new file mode 100644 index 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.py index 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//", + views.ScrobbleShareView.as_view(), + name="shared-detail", + ), path( "scrobbles//", views.ScrobbleDetailView.as_view(), name="detail", ), + path( + "scrobbles//regenerate-share-token/", + views.RegenerateShareTokenView.as_view(), + name="regenerate-share-token", + ), + path( + "scrobbles//change-visibility/", + views.ChangeVisibilityView.as_view(), + name="change-visibility", + ), + path( + "scrobbles//share-analytics/", + views.ScrobbleShareAnalyticsView.as_view(), + name="share-analytics", + ), path( "scrobbles//add-to-mopidy-queue/", views.add_to_mopidy_queue, diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 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.html index f1c6797..d11b273 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -78,6 +78,40 @@

{{ object.logdata.title }}

{% endif %}

{{ object.local_timestamp }}

+ +{% if user.is_authenticated and object.user == user %} +
+ + {{ object.get_visibility_display }} + +
+ {% csrf_token %} + +
+ {% if object.visibility == 'shared' and object.get_share_url %} + Share link: + {{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }} + +
+ {% csrf_token %} + +
+ {% if object.share_view_count %} + {{ object.share_view_count }} view{{ object.share_view_count|pluralize }} + {% endif %} + Analytics + {% endif %} +
+{% endif %} + {% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
{% csrf_token %} diff --git a/vrobbler/templates/scrobbles/scrobble_explore.html b/vrobbler/templates/scrobbles/scrobble_explore.html new file mode 100644 index 0000000..fe978ae --- /dev/null +++ b/vrobbler/templates/scrobbles/scrobble_explore.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} +{% load humanize %} +{% load naturalduration %} + +{% block content %} +
+
+

Explore Public Scrobbles

+
+ +

Recent public scrobbles from all users.

+ +
+ + + + + + + + + + + + {% for scrobble in object_list %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
DateTypeTitleUserTime
+ {{ scrobble.timestamp|naturaltime }} + + {% 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 %} + + {% if scrobble.video %} + {{ scrobble.video.title }} + {% elif scrobble.track %} + {{ scrobble.track.title }} + {% elif scrobble.video_game %} + {{ scrobble.video_game.title }} + {% elif scrobble.book %} + {{ scrobble.book.title }} + {% elif scrobble.food %} + {{ scrobble.food.title }} + {% elif scrobble.beer %} + {{ scrobble.beer.title }} + {% elif scrobble.web_page %} + {{ scrobble.web_page.title }} + {% elif scrobble.podcast_episode %} + {{ scrobble.podcast_episode.title }} + {% elif scrobble.board_game %} + {{ scrobble.board_game.title }} + {% elif scrobble.trail %} + {{ scrobble.trail.title }} + {% elif scrobble.puzzle %} + {{ scrobble.puzzle.title }} + {% elif scrobble.brick_set %} + {{ scrobble.brick_set.title }} + {% elif scrobble.task %} + {{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %} + {% elif scrobble.life_event %} + {{ scrobble.life_event.title }} + {% elif scrobble.mood %} + {{ scrobble.mood.title}} + {% elif scrobble.geo_location %} + {{ scrobble.geo_location.title }} + {% else %} + Unknown + {% endif %} + {{ scrobble.user.username }} + {% if scrobble.playback_position_seconds %} + {{ scrobble.playback_position_seconds|natural_duration }} + {% endif %} +
No public scrobbles found.
+
+ + {% if page_obj.has_previous or page_obj.has_next %} + + {% endif %} +
+{% endblock %} diff --git a/vrobbler/templates/scrobbles/scrobble_share.html b/vrobbler/templates/scrobbles/scrobble_share.html new file mode 100644 index 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 %} + + +{% endblock %} + +{% block lists %} + +
+ +
+ Shared via link +
+ +

+{% 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 %} + {% endif %} + {{ object.media_obj.title }} + {% if object.media_obj.get_absolute_url %} + {% endif %} +

+

{{ object.media_obj.subtitle }}

+

+{% if object.media_type == "SportEvent" %} +{% for team in object.media_obj.teams.all %} + +{% endfor %} +{% endif %} +{% if object.media_type == "Task" and object.logdata.title %} +

+

{{ object.logdata.title }}

+{% endif %} +

{{ object.local_timestamp }}

+{% if object.media_type == "Track" %} +

Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}

+{% endif %} +{% if object.media_type == "Task" and object.log.weight %} +
+
+
Weight
+
{{ object.log.weight }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}
+ {% if object.log.body_fat %} +
Body Fat
+
{{ object.log.body_fat }}%
+ {% endif %} + {% if object.log.bmi %} +
BMI
+
{{ object.log.bmi }}
+ {% endif %} + {% if object.log.muscle %} +
Muscle
+
{{ object.log.muscle }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}
+ {% endif %} + {% if object.log.bone %} +
Bone
+
{{ object.log.bone }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}
+ {% endif %} + {% if object.log.water %} +
Water
+
{{ object.log.water }}%
+ {% endif %} + {% if object.log.visceral_fat %} +
Visceral Fat
+
{{ object.log.visceral_fat }}
+ {% endif %} + {% if object.log.waist %} +
Waist
+
{{ object.log.waist }} {% if object.log.unit_type == "imperial" %}in{% else %}cm{% endif %}
+ {% endif %} + {% if object.log.lbm %} +
Lean Mass
+
{{ object.log.lbm }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}
+ {% endif %} + {% if object.log.calories %} +
Calories
+
{{ object.log.calories }}
+ {% endif %} + {% if object.log.comment %} +
Comment
+
{{ object.log.comment }}
+ {% endif %} +
+
+{% endif %} +{% if object.media_type == "Task" and object.logdata.description %} +

{{ object.logdata.description }}

+{% endif %} + +{% if object.media_type == "Trail" and object.gpx_file %} +
+
+
+{% endif %} + +

+ Tags: + {% if object.tags.all %} + {% for tag in object.tags.all %} + {{ tag.name }} + {% endfor %} + {% else %} + untagged + {% endif %} +

+ +{% with notes_html=object.logdata.notes_as_html %} +{% if notes_html %} +
+

Notes

+ + {% 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 %} + +
+ {{ notes_html|safe }} +
+
+{% endif %} +{% endwith %} + +{% with sentiment=object.log.sentiment %} +{% if sentiment %} +
+
+{% endif %} +{% endwith %} + +{% if object.logdata.avg_seconds_per_page %} +

Rate: {{object.logdata.avg_seconds_per_page}}s per page

+{% endif %} + +{% if object.media_type == "BoardGame" and object.logdata.as_html %} +
+
Game Details
+ {{ object.logdata.as_html|safe }} +
+{% endif %} + +
+ +{% endblock %} + +{% block extra_js %} +{{ block.super }} +{% if object.media_type == "Trail" and object.gpx_file %} + + + +{% endif %} +{% endblock %} diff --git a/vrobbler/templates/scrobbles/scrobble_share_analytics.html b/vrobbler/templates/scrobbles/scrobble_share_analytics.html new file mode 100644 index 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 %} + +
+ +

Share Analytics

+

{{ object.media_obj.title }}

+ +
+ + {{ object.get_visibility_display }} + + {{ object.share_view_count }} view{{ object.share_view_count|pluralize }} +
+ +{% if object.get_share_url %} +
+ Share link: + {{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }} +
+{% endif %} + +

View History

+ +{% if share_views %} + + + + + + + + + + + {% for view in share_views %} + + + + + + + {% endfor %} + +
TimeIP AddressReferrerUser Agent
{{ view.created|date:"M d, Y H:i" }}{{ view.ip_address|default:"-" }} + {% if view.referrer %} + {{ view.referrer }} + {% else %}-{% endif %} + + {{ view.user_agent|default:"-"|truncatechars:60 }} +
+{% else %} +

No views yet. Share the link to see who visits.

+{% endif %} + +Back to scrobble + +
+ +{% endblock %}