Compare commits

..

4 Commits
48.1 ... 48.3

Author SHA1 Message Date
17a7bb52fa [release] Bump to version 48.3
Some checks failed
build / test (push) Failing after 1m39s
deploy / test (push) Failing after 1m39s
deploy / build-and-deploy (push) Has been skipped
- Fix bug in missing sqids dep
2026-06-09 12:37:40 -04:00
bbac142b40 [deps] Add sqids
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:37:19 -04:00
5f55ec557f [release] Bump to version 48.2
Some checks failed
build / test (push) Failing after 1m21s
deploy / test (push) Failing after 1m18s
deploy / build-and-deploy (push) Has been skipped
- Lock down scrobbles and use sqids to share them
2026-06-09 12:33:50 -04:00
7f3076608f [scrobbles] Add sharing of scrobbles
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:33:25 -04:00
22 changed files with 913 additions and 4 deletions

View File

@ -535,6 +535,25 @@ to go try to enrich the media instance. Should this enrichment fail, tag the sc
log a warning and move on.
* Version 48.3 [1/1]
** DONE [#A] Fix bug in missing sqids dep :dependencies:project:
:PROPERTIES:
:ID: 0b619837-729a-cd74-7a97-6fa2a148b27d
:END:
* Version 48.2 [1/1]
** 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:

14
poetry.lock generated
View File

@ -4966,6 +4966,18 @@ files = [
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
]
[[package]]
name = "sqids"
version = "0.5.2"
description = "Generate YouTube-like ids from numbers."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "sqids-0.5.2-py3-none-any.whl", hash = "sha256:0089ba823e21fd44290c7225f02fb0b5140c36e41959c04d86d3f6f2513799be"},
{file = "sqids-0.5.2.tar.gz", hash = "sha256:5ac08f0c5c9b6814bc2e7c79ee5931e0849d25d95c50e415771b022a44f58af9"},
]
[[package]]
name = "sqlparse"
version = "0.5.5"
@ -6020,4 +6032,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.15"
content-hash = "78ba52d0e6ea492efceb14fcd42ace25abfb66d42c3aff28f2fe1a31a9aa03b5"
content-hash = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "48.1"
version = "48.3"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -63,6 +63,7 @@ gpxpy = "^1.6.2"
fitparse = "^1.2.0"
lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
[tool.poetry.group.test]
optional = true

View File

@ -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,
),
),
]

View File

@ -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,
),
),
]

View File

@ -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(

View File

@ -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")

View File

@ -1,6 +1,7 @@
import re
from rest_framework import serializers
from scrobbles.constants import Visibility
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,

View File

@ -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")

View File

@ -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 = {

View File

@ -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,
),
),
]

View File

@ -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,
),
]

View File

@ -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),
),
]

View File

@ -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,
},
),
]

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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"

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}