This commit has no recorded session.
diff
commit b7df6299d0b51f9b928333861fc01a9ae8fd6250Author: Colin Powell <colin@unbl.ink>Date: Tue Jun 9 17:18:51 2026 -0400 [sharing] Add bulk scrobble share management@@ -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:@@ -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 cleaned@@ -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"}',+ ),+ ),+ ]@@ -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(@@ -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",+ ), ]@@ -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 ctx@@ -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_LOCATION@@ -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 qs@@ -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" %}@@ -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' %}">« 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 %}@@ -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>