Compare commits

...

6 Commits
48.1 ... 49.0

Author SHA1 Message Date
68f821fce1 [release] Bump to version 49.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Failing after 2m20s
- Fix broken tests with new sharing and add tests
2026-06-09 12:48:22 -04:00
ed2ed59f65 [scrobbles] Fix tests around visbility 2026-06-09 12:48:01 -04:00
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
23 changed files with 1231 additions and 6 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/14] :vrobbler:project:personal:
* Backlog [0/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
@ -534,7 +534,48 @@ async with the POST data stored in the log["raw_data"] and used by the celery en
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
log a warning and move on.
** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:
:PROPERTIES:
:ID: 9ed2ec65-bf69-4300-965c-6a7d3ef7ea03
:END:
*** Description
We now have the ability to share or unshare scrobbles and create private links. We should add a toggle
in the user's settings that will bulk make all their scrobbles public or private, so that a user
can either share everything, or lock their account down.
This should not affect scrobbles that are in the "Shared" visibility state.
Additionally, users's should have links in their settings to see what scrobbles
are either public, shared or private. Probably this could be done with a
?visbility=<> filter on the /scrobbles/ page.
* Version 49.0 [1/1]
** DONE [#A] Fix broken tests with new sharing and add tests :scrobbles:sharing:tests:
:PROPERTIES:
:ID: 10ecd169-eaee-8554-d4ee-f1d34bfad99f
:END:
* 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 = "49.0"
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

@ -8,7 +8,8 @@ from django.urls import reverse
from django.utils import timezone
from music.models import Album, Artist, Track
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from scrobbles.models import Scrobble, ShareViewLog
from scrobbles.sqids import encode_scrobble_share
from tasks.models import Task
@ -512,6 +513,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": ["First note", "Second note"],
"description": "Test description",
@ -534,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
@ -562,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
@ -739,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token):
)
assert response.status_code == 200
assert "scrobble_id" in response.data
@pytest.mark.django_db
def test_share_view_shared_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_public_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser2", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="public",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_private_visibility_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser3", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_invalid_sqid_returns_404(client):
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_expired_token_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser4", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
scrobble.regenerate_share_token()
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_increments_count_and_logs_view(client):
user = get_user_model().objects.create_user(
username="shareuser5", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
assert scrobble.share_view_count == 0
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
scrobble.refresh_from_db()
assert scrobble.share_view_count == 1
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
assert log_entry.ip_address == "127.0.0.1"
assert log_entry.user_agent == ""
assert log_entry.referrer == ""
@pytest.mark.django_db
def test_explore_view_shows_only_public_scrobbles(client):
user = get_user_model().objects.create_user(
username="exploreuser", password="testpass"
)
public_task = Task.objects.create(title="Public Task Title")
shared_task = Task.objects.create(title="Shared Task Title")
private_task = Task.objects.create(title="Private Task Title")
ts = timezone.now()
public_scrobble = Scrobble.objects.create(
task=public_task, media_type="Task", user=user, visibility="public",
timestamp=ts,
)
Scrobble.objects.create(
task=shared_task, media_type="Task", user=user, visibility="shared",
timestamp=ts,
)
Scrobble.objects.create(
task=private_task, media_type="Task", user=user, visibility="private",
timestamp=ts,
)
url = reverse("scrobbles:explore")
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Public Task Title" in content
assert "Shared Task Title" not in content
assert "Private Task Title" not in content
@pytest.mark.django_db
def test_change_visibility_owner_can_change(client):
user = get_user_model().objects.create_user(
username="visuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.visibility == "shared"
@pytest.mark.django_db
def test_change_visibility_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="owner", password="testpass"
)
other = get_user_model().objects.create_user(
username="other", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="private",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 404
scrobble.refresh_from_db()
assert scrobble.visibility == "private"
@pytest.mark.django_db
def test_change_visibility_anonymous_redirects_to_login(client):
user = get_user_model().objects.create_user(
username="anontest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
assert "/login/" in response.url
@pytest.mark.django_db
def test_regenerate_share_token_invalidates_old_sqid(client):
user = get_user_model().objects.create_user(
username="regentest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
client.force_login(user)
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
response = client.post(url)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.share_token_version == 1
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
old_response = client.get(old_url)
assert old_response.status_code == 404
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
new_response = client.get(new_url)
assert new_response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_owner_can_view(client):
user = get_user_model().objects.create_user(
username="analyticsuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="analyticsowner", password="testpass"
)
other = get_user_model().objects.create_user(
username="analyticsother", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_analytics_shows_view_logs(client):
user = get_user_model().objects.create_user(
username="analyticsviews", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
client.get(share_url)
client.get(share_url)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "127.0.0.1" in content
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2

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