sessions / b7df6299d0b51f9b928333861fc01a9ae8fd6250

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

This commit has no recorded session.

diff

ignore whitespace

commit b7df6299d0b51f9b928333861fc01a9ae8fd6250Author: Colin Powell <colin@unbl.ink>Date:   Tue Jun 9 17:18:51 2026 -0400    [sharing] Add bulk scrobble share managementdiff --git a/PROJECT.org b/PROJECT.orgindex f75d800..d11fa59 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [1/17] :vrobbler:project:personal:+* Backlog [2/17] :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@@ -544,22 +544,43 @@ log a warning and move on. We should have a global view `/favorites/` that shows the logged in users's favorited media objects. -** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:+** DONE [#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.+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. +And users should be able to also control whether all scrobbles of a specific+type are shared or not. Maybe this could be a JSONField in profile that contains+a media_type key with a visibility type for a value, and if it's not present,+sharing defaults to private?+ 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.++*** Changes++- Added `media_type_visibility` JSONField to UserProfile (migration 0038)+- Created `BulkVisibilityView` at `/settings/visibility/` with:+  - Radio toggle to make all non-shared scrobbles Public or Private+  - Per-media-type dropdown for each of the 20 media types (inherit/public/shared/private)+- Created `BulkVisibilityForm` with dynamic media_type fields+- Created `profiles/visibility_settings.html` template with visibility stats + filter links+- Added link from main settings page to visibility settings+- Added `?visibility=` filter support to `ScrobbleListView` (public/shared/private)+- Added filter indicator to `scrobble_all_list.html`+- Updated `Scrobble.create()` to check `user.profile.media_type_visibility` for media-type-specific defaults before falling back to PRIVATE++           ** DONE [#A] Replace columsn of Top Artists, Tracks and Series with Maloja widget :templates:charts: :PROPERTIES:diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.pyindex 35ba2e8..8dc2833 100644--- a/vrobbler/apps/profiles/forms.py+++ b/vrobbler/apps/profiles/forms.py@@ -1,6 +1,8 @@ from django import forms  from profiles.models import UserProfile+from scrobbles.constants import Visibility+from scrobbles.models import Scrobble   class UserProfileForm(forms.ModelForm):@@ -45,3 +47,51 @@ class UserProfileForm(forms.ModelForm):             "archivebox_password": forms.PasswordInput(render_value=True),             "webdav_pass": forms.PasswordInput(render_value=True),         }+++MEDIA_TYPE_LABELS = {+    mt.value: mt.label for mt in Scrobble.MediaType+}++INHERIT = ""+++class BulkVisibilityForm(forms.Form):+    bulk_action = forms.ChoiceField(+        choices=[+            (Visibility.PUBLIC, "Public"),+            (Visibility.PRIVATE, "Private"),+        ],+        widget=forms.RadioSelect,+        required=False,+        label="Set all non-shared scrobbles to",+    )++    def __init__(self, *args, **kwargs):+        self.profile = kwargs.pop("profile")+        super().__init__(*args, **kwargs)+        media_types = Scrobble.MediaType.values+        choices = [+            (Visibility.PUBLIC, "Public"),+            (Visibility.SHARED, "Shared"),+            (Visibility.PRIVATE, "Private"),+        ]+        existing_overrides = self.profile.media_type_visibility or {}+        for mt in sorted(media_types):+            label = MEDIA_TYPE_LABELS.get(mt, mt)+            self.fields[f"media_type_{mt}"] = forms.ChoiceField(+                choices=choices,+                required=False,+                label=label,+                initial=existing_overrides.get(mt, Visibility.PRIVATE),+            )++    def clean(self):+        cleaned = super().clean()+        overrides = {}+        for mt in Scrobble.MediaType.values:+            val = cleaned.get(f"media_type_{mt}")+            if val:+                overrides[mt] = val+        cleaned["media_type_visibility"] = overrides+        return cleaneddiff --git a/vrobbler/apps/profiles/migrations/0038_userprofile_media_type_visibility.py b/vrobbler/apps/profiles/migrations/0038_userprofile_media_type_visibility.pynew file mode 100644index 0000000..951ba92--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0038_userprofile_media_type_visibility.py@@ -0,0 +1,20 @@+from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("profiles", "0037_alter_userprofile_default_scrobble_visibility"),+    ]++    operations = [+        migrations.AddField(+            model_name="userprofile",+            name="media_type_visibility",+            field=models.JSONField(+                blank=True,+                default=dict,+                help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',+            ),+        ),+    ]diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.pyindex 25df542..25ddb9f 100644--- a/vrobbler/apps/profiles/models.py+++ b/vrobbler/apps/profiles/models.py@@ -90,6 +90,12 @@ class UserProfile(TimeStampedModel):         default="private",     ) +    media_type_visibility = models.JSONField(+        default=dict,+        blank=True,+        help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",+    )+     home_scrobble_limit = models.IntegerField(default=20)      weigh_in_units = models.CharField(diff --git a/vrobbler/apps/profiles/urls.py b/vrobbler/apps/profiles/urls.pyindex ffa333a..f70778e 100644--- a/vrobbler/apps/profiles/urls.py+++ b/vrobbler/apps/profiles/urls.py@@ -6,4 +6,9 @@ app_name = "profiles"  urlpatterns = [     path("settings/", views.ProfileFormView.as_view(), name="profile_settings"),+    path(+        "settings/visibility/",+        views.BulkVisibilityView.as_view(),+        name="bulk_visibility",+    ), ]diff --git a/vrobbler/apps/profiles/views.py b/vrobbler/apps/profiles/views.pyindex 4dfb1a4..09beb87 100644--- a/vrobbler/apps/profiles/views.py+++ b/vrobbler/apps/profiles/views.py@@ -1,8 +1,12 @@+from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin+from django.db.models import Count, Q from django.http.response import HttpResponseBadRequest from django.urls import reverse_lazy from django.views.generic import FormView-from profiles.forms import UserProfileForm+from profiles.forms import BulkVisibilityForm, UserProfileForm+from scrobbles.constants import Visibility+from scrobbles.models import Scrobble from tasks.todoist import generate_todoist_oauth_url  @@ -30,3 +34,46 @@ class ProfileFormView(LoginRequiredMixin, FormView):         context["profile"] = self.request.user.profile         context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)         return context+++class BulkVisibilityView(LoginRequiredMixin, FormView):+    template_name = "profiles/visibility_settings.html"+    form_class = BulkVisibilityForm+    success_url = reverse_lazy("profiles:bulk_visibility")++    def get_form_kwargs(self):+        kwargs = super().get_form_kwargs()+        kwargs["profile"] = self.request.user.profile+        return kwargs++    def form_valid(self, form):+        request = self.request+        profile = request.user.profile++        bulk_action = form.cleaned_data.get("bulk_action")+        if bulk_action:+            qs = Scrobble.objects.filter(+                user=request.user,+            ).exclude(visibility=Visibility.SHARED)+            total = qs.count()+            qs.update(visibility=bulk_action)+            messages.success(+                request,+                f"Updated {total} scrobble(s) to {bulk_action}.",+            )++        profile.media_type_visibility = form.cleaned_data["media_type_visibility"]+        profile.save(update_fields=["media_type_visibility"])+        messages.success(request, "Per-media-type visibility overrides saved.")+        return super().form_valid(form)++    def get_context_data(self, **kwargs):+        ctx = super().get_context_data(**kwargs)+        profile = self.request.user.profile+        qs = Scrobble.objects.filter(user=self.request.user)+        ctx["scrobble_count"] = qs.count()+        ctx["visibility_counts"] = qs.values("visibility").annotate(+            count=Count("id")+        )+        ctx["profile"] = profile+        return ctxdiff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 985ab5d..cb52d61 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -1613,7 +1613,17 @@ class Scrobble(TimeStampedModel):         scrobble_data: dict,     ) -> "Scrobble":         if "visibility" not in scrobble_data:-            scrobble_data["visibility"] = Visibility.PRIVATE+            user = scrobble_data.get("user")+            media_type = scrobble_data.get("media_type")+            override = None+            if user and media_type:+                try:+                    profile = user.profile+                    overrides = profile.media_type_visibility or {}+                    override = overrides.get(media_type)+                except user.__class__.profile.RelatedObjectDoesNotExist:+                    pass+            scrobble_data["visibility"] = override or Visibility.PRIVATE         scrobble = cls.objects.create(**scrobble_data)         if not (             scrobble.media_type == cls.MediaType.GEO_LOCATIONdiff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex 9ffa153..464a3f3 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -371,6 +371,9 @@ class ScrobbleListView(LoginRequiredMixin, ListView):                 qs = qs.filter(id__in=matching_ids).distinct()         else:             tag_list = []+        visibility_param = self.request.GET.get("visibility", "")+        if visibility_param in ("public", "shared", "private"):+            qs = qs.filter(visibility=visibility_param)         self.tag_list = tag_list         self._full_queryset = qs         return qsdiff --git a/vrobbler/templates/profiles/settings_form.html b/vrobbler/templates/profiles/settings_form.htmlindex 46c928a..e261d35 100644--- a/vrobbler/templates/profiles/settings_form.html+++ b/vrobbler/templates/profiles/settings_form.html@@ -106,6 +106,7 @@ {% block title %}Settings{% endblock %} {% block details %} <p class="settings-link"><a href="{% url 'people:person_form' %}">Manage People</a></p>+<p class="settings-link"><a href="{% url 'profiles:bulk_visibility' %}">Scrobble Visibility Settings</a></p> <form method="post" class="settings-form">{% csrf_token %}     {% for field in form %}         {% if field.name == "enable_public_widgets" %}diff --git a/vrobbler/templates/profiles/visibility_settings.html b/vrobbler/templates/profiles/visibility_settings.htmlnew file mode 100644index 0000000..69e50b0--- /dev/null+++ b/vrobbler/templates/profiles/visibility_settings.html@@ -0,0 +1,117 @@+{% extends "base_detail.html" %}++{% block title %}Scrobble Visibility Settings{% endblock %}++{% block head_extra %}+<style>+.vis-form {+    max-width: 700px;+}+.vis-form h3 {+    margin-top: 24px;+    margin-bottom: 12px;+}+.vis-form h4 {+    margin-top: 20px;+    margin-bottom: 8px;+    font-size: 1.1rem;+}+.vis-form label {+    font-weight: 600;+    margin-right: 12px;+}+.vis-form .bulk-radio-group {+    margin: 8px 0 16px 0;+}+.vis-form .bulk-radio-group label {+    font-weight: normal;+    display: block;+    margin: 4px 0;+}+.vis-form .media-type-row {+    display: flex;+    align-items: center;+    gap: 12px;+    margin: 6px 0;+    padding: 4px 8px;+    border-radius: 4px;+}+.vis-form .media-type-row:nth-child(even) {+    background: #f8f9fa;+}+.vis-form .media-type-row label {+    font-weight: normal;+    min-width: 130px;+}+.vis-form select {+    padding: 4px 8px;+    border: 1px solid #ccc;+    border-radius: 4px;+}+.vis-form input[type="submit"] {+    margin-top: 20px;+    padding: 10px 24px;+    background: #007bff;+    color: white;+    border: none;+    border-radius: 4px;+    cursor: pointer;+    font-size: 14px;+}+.vis-form input[type="submit"]:hover {+    background: #0056b3;+}+.vis-stats {+    margin: 16px 0;+    padding: 12px;+    background: #f8f9fa;+    border-radius: 4px;+}+.vis-stats strong {+    display: block;+    margin-bottom: 6px;+}+.vis-stats ul {+    margin: 0;+    padding-left: 18px;+}+</style>+{% endblock %}++{% block details %}+<p><a href="{% url 'profiles:profile_settings' %}">&laquo; Back to settings</a></p>++<div class="vis-stats">+    <strong>Current scrobble visibility ({{ scrobble_count }} total)</strong>+    <ul>+        {% for item in visibility_counts %}+        <li><a href="{% url 'scrobbles:scrobble-list' %}?visibility={{ item.visibility }}">{{ item.visibility|title }}</a>: {{ item.count }}</li>+        {% endfor %}+    </ul>+</div>++<form method="post" class="vis-form">{% csrf_token %}+    <h3>Bulk Update</h3>+    <p>Choose a visibility to apply to all scrobbles that are <strong>not</strong> currently "Shared".</p>+    <div class="bulk-radio-group">+        {% for radio in form.bulk_action %}+        <label>{{ radio.tag }} {{ radio.choice_label }}</label>+        {% endfor %}+    </div>++    <details>+        <summary><h3 style="display:inline">Per-Media-Type Defaults</h3></summary>+        <p>Override default visibility for new scrobbles of specific types.</p>+        {% for field in form %}+            {% if field.name != "bulk_action" %}+            <div class="media-type-row">+                <label for="{{ field.id_for_label }}">{{ field.label }}</label>+                {{ field }}+            </div>+            {% endif %}+        {% endfor %}+    </details>++    <input type="submit" value="Save Visibility Settings">+</form>+{% endblock %}diff --git a/vrobbler/templates/scrobbles/scrobble_all_list.html b/vrobbler/templates/scrobbles/scrobble_all_list.htmlindex bc89105..6a8634e 100644--- a/vrobbler/templates/scrobbles/scrobble_all_list.html+++ b/vrobbler/templates/scrobbles/scrobble_all_list.html@@ -13,6 +13,9 @@             <p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>             {% endif %}             {% endif %}+            {% if request.GET.visibility %}+            <h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>+            {% endif %}         </div>     </div>