Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f55ec557f | |||
| 7f3076608f |
13
PROJECT.org
13
PROJECT.org
@ -535,6 +535,19 @@ to go try to enrich the media instance. Should this enrichment fail, tag the sc
|
|||||||
log a warning and move on.
|
log a warning and move on.
|
||||||
|
|
||||||
|
|
||||||
|
* 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]
|
* Version 48.1 [2/2]
|
||||||
** DONE [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata:
|
** DONE [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "48.1"
|
version = "48.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -9,6 +9,11 @@ from django.utils.functional import cached_property
|
|||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from encrypted_field import EncryptedField
|
from encrypted_field import EncryptedField
|
||||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||||
|
VISIBILITY_CHOICES = (
|
||||||
|
("public", "Public"),
|
||||||
|
("shared", "Shared"),
|
||||||
|
("private", "Private"),
|
||||||
|
)
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -79,6 +84,12 @@ class UserProfile(TimeStampedModel):
|
|||||||
enable_public_widgets = models.BooleanField(default=False)
|
enable_public_widgets = models.BooleanField(default=False)
|
||||||
widget_custom_css = models.TextField(**BNULL)
|
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)
|
home_scrobble_limit = models.IntegerField(default=20)
|
||||||
|
|
||||||
weigh_in_units = models.CharField(
|
weigh_in_units = models.CharField(
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from scrobbles.models import (
|
|||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
ScaleCSVImport,
|
ScaleCSVImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
from scrobbles.mixins import Genre
|
||||||
@ -116,6 +117,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"in_progress",
|
"in_progress",
|
||||||
"is_paused",
|
"is_paused",
|
||||||
"played_to_completion",
|
"played_to_completion",
|
||||||
|
"visibility",
|
||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
raw_id_fields = (
|
raw_id_fields = (
|
||||||
@ -143,6 +145,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"is_paused",
|
"is_paused",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
"media_type",
|
"media_type",
|
||||||
|
"visibility",
|
||||||
"long_play_complete",
|
"long_play_complete",
|
||||||
"source",
|
"source",
|
||||||
"timezone",
|
"timezone",
|
||||||
@ -161,6 +164,14 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
return qs
|
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)
|
@admin.register(FavoriteMedia)
|
||||||
class FavoriteMediaAdmin(admin.ModelAdmin):
|
class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||||
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from scrobbles.constants import Visibility
|
||||||
from scrobbles.models import (
|
from scrobbles.models import (
|
||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from rest_framework import permissions, viewsets
|
from rest_framework import permissions, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
from scrobbles.api.serializers import (
|
from scrobbles.api.serializers import (
|
||||||
AudioScrobblerTSVImportSerializer,
|
AudioScrobblerTSVImportSerializer,
|
||||||
KoReaderImportSerializer,
|
KoReaderImportSerializer,
|
||||||
@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset().filter(user=self.request.user)
|
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):
|
class KoReaderImportViewSet(viewsets.ModelViewSet):
|
||||||
queryset = KoReaderImport.objects.all().order_by("-created")
|
queryset = KoReaderImport.objects.all().order_by("-created")
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
|
from django.db import models
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||||
|
|
||||||
|
class Visibility(models.TextChoices):
|
||||||
|
PUBLIC = "public", "Public"
|
||||||
|
SHARED = "shared", "Shared"
|
||||||
|
PRIVATE = "private", "Private"
|
||||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||||
|
|
||||||
LONG_PLAY_MEDIA = {
|
LONG_PLAY_MEDIA = {
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -18,6 +18,8 @@ from bricksets.models import BrickSet
|
|||||||
from charts.utils import build_charts
|
from charts.utils import build_charts
|
||||||
from dataclass_wizard.errors import ParseError
|
from dataclass_wizard.errors import ParseError
|
||||||
from django.conf import settings
|
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.contrib.auth import get_user_model
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import models
|
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):
|
class Scrobble(TimeStampedModel):
|
||||||
"""A scrobble tracks played media items by a user."""
|
"""A scrobble tracks played media items by a user."""
|
||||||
|
|
||||||
@ -694,6 +708,14 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_type = models.CharField(
|
media_type = models.CharField(
|
||||||
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
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)
|
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
|
||||||
|
|
||||||
# Time keeping
|
# Time keeping
|
||||||
@ -875,6 +897,16 @@ class Scrobble(TimeStampedModel):
|
|||||||
self.save(update_fields=["uuid"])
|
self.save(update_fields=["uuid"])
|
||||||
return reverse("scrobbles:detail", kwargs={"uuid": self.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):
|
def push_to_archivebox(self):
|
||||||
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
|
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
|
||||||
self.media_obj.push_to_archivebox
|
self.media_obj.push_to_archivebox
|
||||||
|
|||||||
36
vrobbler/apps/scrobbles/sqids.py
Normal file
36
vrobbler/apps/scrobbles/sqids.py
Normal 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)
|
||||||
@ -153,11 +153,32 @@ urlpatterns = [
|
|||||||
name="long-plays",
|
name="long-plays",
|
||||||
),
|
),
|
||||||
path("scrobbles/", views.ScrobbleListView.as_view(), name="scrobble-list"),
|
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(
|
path(
|
||||||
"scrobbles/<slug:uuid>/",
|
"scrobbles/<slug:uuid>/",
|
||||||
views.ScrobbleDetailView.as_view(),
|
views.ScrobbleDetailView.as_view(),
|
||||||
name="detail",
|
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(
|
path(
|
||||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
||||||
views.add_to_mopidy_queue,
|
views.add_to_mopidy_queue,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
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 django.db.models.query import QuerySet
|
||||||
from rest_framework.authentication import TokenAuthentication
|
from rest_framework.authentication import TokenAuthentication
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
@ -38,7 +38,7 @@ from django.utils import timezone
|
|||||||
from django.utils.dateformat import DateFormat
|
from django.utils.dateformat import DateFormat
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_POST
|
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.edit import CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from moods.models import Mood
|
from moods.models import Mood
|
||||||
@ -72,6 +72,8 @@ from scrobbles.constants import (
|
|||||||
)
|
)
|
||||||
from scrobbles.export import export_scrobbles
|
from scrobbles.export import export_scrobbles
|
||||||
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||||
|
from scrobbles.constants import Visibility
|
||||||
|
from scrobbles.sqids import decode_scrobble_share
|
||||||
from scrobbles.models import (
|
from scrobbles.models import (
|
||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
@ -83,6 +85,7 @@ from scrobbles.models import (
|
|||||||
ScaleCSVImport,
|
ScaleCSVImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
ScrobbleQuerySet,
|
ScrobbleQuerySet,
|
||||||
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
)
|
)
|
||||||
from scrobbles.scrobblers import *
|
from scrobbles.scrobblers import *
|
||||||
@ -1136,6 +1139,15 @@ class ScrobbleDetailView(DetailView):
|
|||||||
slug_url_kwarg = "uuid"
|
slug_url_kwarg = "uuid"
|
||||||
paginate_by = 100
|
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):
|
def get_form_class(self):
|
||||||
return self.object.media_obj.logdata_cls().form()
|
return self.object.media_obj.logdata_cls().form()
|
||||||
|
|
||||||
@ -1252,6 +1264,93 @@ class ScrobbleDetailView(DetailView):
|
|||||||
return context
|
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):
|
class BaseEmbeddableWidget(TemplateView):
|
||||||
template_name = "scrobbles/embeddable_top_media.html"
|
template_name = "scrobbles/embeddable_top_media.html"
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,40 @@
|
|||||||
<h2>{{ object.logdata.title }}</h2>
|
<h2>{{ object.logdata.title }}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
<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 %}
|
{% 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">
|
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
140
vrobbler/templates/scrobbles/scrobble_explore.html
Normal file
140
vrobbler/templates/scrobbles/scrobble_explore.html
Normal 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 %}
|
||||||
200
vrobbler/templates/scrobbles/scrobble_share.html
Normal file
200
vrobbler/templates/scrobbles/scrobble_share.html
Normal 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: '© <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 %}
|
||||||
68
vrobbler/templates/scrobbles/scrobble_share_analytics.html
Normal file
68
vrobbler/templates/scrobbles/scrobble_share_analytics.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user