diff --git a/PROJECT.org b/PROJECT.org index fb2b971..04fad1b 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [1/24] :vrobbler:project:personal: +* Backlog [2/25] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: @@ -605,6 +605,10 @@ independent of the email flow it was originally creatdd for ** TODO [#B] Is there way to create unique slugs for media instances :media_types: +** DONE [#B] Allow people all trends or individual trends :trends:profiles: +:PROPERTIES: +:ID: 1d081152-abd1-73c2-a625-903565a10c6c +:END: ** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata: :PROPERTIES: :ID: 014bab30-13bf-fae7-e678-4666a8d38ae4 diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.py index 35e10d1..966a747 100644 --- a/vrobbler/apps/profiles/forms.py +++ b/vrobbler/apps/profiles/forms.py @@ -42,6 +42,7 @@ class UserProfileForm(forms.ModelForm): "home_scrobble_limit", "live_now_playing", "weigh_in_units", + "trends_disabled", ] widgets = { "lastfm_password": forms.PasswordInput(render_value=True), diff --git a/vrobbler/apps/profiles/migrations/0040_userprofile_trends_disabled.py b/vrobbler/apps/profiles/migrations/0040_userprofile_trends_disabled.py new file mode 100644 index 0000000..af30c34 --- /dev/null +++ b/vrobbler/apps/profiles/migrations/0040_userprofile_trends_disabled.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.29 on 2026-06-25 20:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0039_userprofile_live_now_playing"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="disabled_trends", + field=models.JSONField( + blank=True, + default=list, + help_text="List of trend slugs the user has disabled", + ), + ), + migrations.AddField( + model_name="userprofile", + name="trends_disabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.py index 4dc467b..0c982b3 100644 --- a/vrobbler/apps/profiles/models.py +++ b/vrobbler/apps/profiles/models.py @@ -106,6 +106,14 @@ class UserProfile(TimeStampedModel): default=WeighUnit.METRIC, ) + trends_disabled = models.BooleanField(default=False) + + disabled_trends = models.JSONField( + default=list, + blank=True, + help_text="List of trend slugs the user has disabled", + ) + def __str__(self): return f"User profile for {self.user}" diff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.py index 76e2027..30088a7 100644 --- a/vrobbler/apps/trends/management/commands/compute_trends.py +++ b/vrobbler/apps/trends/management/commands/compute_trends.py @@ -38,15 +38,36 @@ class Command(BaseCommand): overall_start = timezone.now() ok_count = 0 fail_count = 0 + skipped_count = 0 for user in users: - total_trends = len(TREND_REGISTRY) - self.stdout.write(f" {user} ({user.id}): {total_trends} trends...") + try: + profile = user.profile + if profile.trends_disabled: + self.stdout.write( + self.style.WARNING( + f" {user} ({user.id}): trends disabled globally, skipping" + ) + ) + skipped_count += len(TREND_REGISTRY) + continue + disabled_trends = set(profile.disabled_trends or []) + except Exception: + disabled_trends = set() + + active_slugs = [ + s for s in TREND_REGISTRY if s not in disabled_trends + ] + total_trends = len(active_slugs) + self.stdout.write( + f" {user} ({user.id}): {total_trends} trends (" + f"{len(disabled_trends & set(TREND_REGISTRY.keys()))} disabled)..." + ) user_start = timezone.now() user_ok = 0 user_fail = 0 - for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): + for idx, slug in enumerate(active_slugs, start=1): periods = get_supported_periods(slug) self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n") for period in periods: @@ -76,7 +97,7 @@ class Command(BaseCommand): overall_elapsed = (timezone.now() - overall_start).total_seconds() self.stdout.write( self.style.SUCCESS( - f"Done! {ok_count} OK, {fail_count} failed " + f"Done! {ok_count} OK, {fail_count} failed, {skipped_count} skipped " f"({overall_elapsed:.1f}s across {total_users} user(s))" ) ) diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.py index d7e3fb7..260250d 100644 --- a/vrobbler/apps/trends/tasks.py +++ b/vrobbler/apps/trends/tasks.py @@ -26,7 +26,17 @@ def compute_user_trends(user_id): logger.warning("User %s not found, skipping trends", user_id) return - total = len(TREND_REGISTRY) + try: + profile = user.profile + if profile.trends_disabled: + logger.info("User %s (%d) has trends disabled, skipping", user, user_id) + return + disabled = set(profile.disabled_trends or []) + except Exception: + disabled = set() + + active_slugs = [s for s in TREND_REGISTRY if s not in disabled] + total = len(active_slugs) logger.info( "Computing %d trends for user %s (%d)", total, @@ -34,7 +44,7 @@ def compute_user_trends(user_id): user_id, ) - for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): + for idx, slug in enumerate(active_slugs, start=1): compute_single_trend.delay(user_id, slug) logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id) @@ -52,6 +62,21 @@ def compute_single_trend(user_id, slug): logger.warning("Unknown trend slug '%s' for user %d", slug, user_id) return + try: + profile = user.profile + if profile.trends_disabled: + logger.info( + "User %d has trends disabled, skipping '%s'", user_id, slug + ) + return + if slug in (profile.disabled_trends or []): + logger.info( + "User %d has trend '%s' disabled, skipping", user_id, slug + ) + return + except Exception: + pass + periods = get_supported_periods(slug) for period in periods: diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.html index 3884243..20b028d 100644 --- a/vrobbler/apps/trends/templates/trends/trend_detail.html +++ b/vrobbler/apps/trends/templates/trends/trend_detail.html @@ -3,6 +3,10 @@ {% block title %}{{ trend.title }}{% endblock %} {% block lists %} +{% if trend_not_found %} +
Trend not found.
+ +{% else %}
← All Trends @@ -35,13 +39,21 @@ {% if computed_at %} Last computed: {{ computed_at|date:"F j, Y H:i" }} {% endif %} + +
+
+ {% csrf_token %} + {% if trend_disabled %} + + {% else %} + + {% endif %} +
+
-{% if trend_not_found %} -
Trend not found.
- -{% elif data is None %} +{% if data is None %}
No data computed yet for this period. Trends are updated once daily, check back later.
@@ -85,5 +97,6 @@ {% elif trend.slug == "mood-weather" %} {% include "trends/_mood_weather.html" %} +{% endif %} {% endif %} {% endblock %} diff --git a/vrobbler/apps/trends/templates/trends/trend_list.html b/vrobbler/apps/trends/templates/trends/trend_list.html index a204b3d..93d4bd6 100644 --- a/vrobbler/apps/trends/templates/trends/trend_list.html +++ b/vrobbler/apps/trends/templates/trends/trend_list.html @@ -25,11 +25,23 @@

{{ trend.description }}

- {% if trend.computed_at %} - Last computed: {{ trend.computed_at|date:"M j, Y H:i" }} - {% else %} - Pending - {% endif %} +
+
+ {% if trend.computed_at %} + Last computed: {{ trend.computed_at|date:"M j, Y H:i" }} + {% else %} + Pending + {% endif %} +
+
+ {% csrf_token %} + {% if trend.disabled %} + + {% else %} + + {% endif %} +
+
diff --git a/vrobbler/apps/trends/urls.py b/vrobbler/apps/trends/urls.py index f0c4071..c12cce8 100644 --- a/vrobbler/apps/trends/urls.py +++ b/vrobbler/apps/trends/urls.py @@ -1,9 +1,18 @@ from django.urls import path -from trends.views import TrendDetailView, TrendListView +from trends.views import ( + ToggleTrendDisabledView, + TrendDetailView, + TrendListView, +) app_name = "trends" urlpatterns = [ path("trends/", TrendListView.as_view(), name="trends-home"), path("trends//", TrendDetailView.as_view(), name="trend-detail"), + path( + "trends//toggle/", + ToggleTrendDisabledView.as_view(), + name="trend-toggle-disabled", + ), ] diff --git a/vrobbler/apps/trends/utils.py b/vrobbler/apps/trends/utils.py index 7f739cd..3fbf292 100644 --- a/vrobbler/apps/trends/utils.py +++ b/vrobbler/apps/trends/utils.py @@ -3,6 +3,7 @@ from datetime import timedelta from django.utils import timezone from trends.models import PERIOD_CHOICES, TrendResult +from trends.trends import TREND_REGISTRY logger = logging.getLogger(__name__) @@ -35,6 +36,13 @@ TREND_PERIOD_OVERRIDES = { } +def get_disabled_trends(user): + profile = user.profile + if profile.trends_disabled: + return set(TREND_REGISTRY.keys()) + return set(profile.disabled_trends or []) + + def get_supported_periods(trend_slug): if trend_slug in TREND_PERIOD_OVERRIDES: slugs = TREND_PERIOD_OVERRIDES[trend_slug] diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.py index 8ad2e21..5a5298f 100644 --- a/vrobbler/apps/trends/views.py +++ b/vrobbler/apps/trends/views.py @@ -1,8 +1,11 @@ +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import TemplateView +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.views.generic import TemplateView, View from trends.models import TrendResult from trends.trends import TREND_REGISTRY -from trends.utils import get_period_nav, get_supported_periods +from trends.utils import get_disabled_trends, get_period_nav, get_supported_periods TREND_METADATA = { "activity-distribution": { @@ -78,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) + disabled = get_disabled_trends(self.request.user) + results = TrendResult.objects.filter( user=self.request.user, ).order_by("trend_slug", "-computed_at") @@ -89,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView): trends = [] for slug in TREND_REGISTRY: + if slug in disabled: + continue meta = TREND_METADATA.get(slug, {}) result = latest_by_slug.get(slug) + is_disabled = slug in (self.request.user.profile.disabled_trends or []) trends.append( { "slug": slug, @@ -99,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView): "icon": meta.get("icon", ""), "computed_at": result.computed_at if result else None, "has_data": result is not None, + "disabled": is_disabled, } ) ctx["trends"] = trends @@ -148,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView): ctx["computed_at"] = None ctx["data"] = None + disabled = get_disabled_trends(self.request.user) + ctx["trend_disabled"] = slug in disabled + ctx["globally_disabled"] = self.request.user.profile.trends_disabled + return ctx + + +class ToggleTrendDisabledView(LoginRequiredMixin, View): + def post(self, request, trend_slug): + profile = request.user.profile + disabled = set(profile.disabled_trends or []) + if trend_slug in disabled: + disabled.discard(trend_slug) + messages.success(request, f"Trend re-enabled.") + else: + disabled.add(trend_slug) + messages.success(request, f"Trend disabled.") + profile.disabled_trends = list(disabled) + profile.save(update_fields=["disabled_trends"]) + return HttpResponseRedirect( + request.META.get("HTTP_REFERER", reverse("trends:trends-home")) + ) diff --git a/vrobbler/templates/profiles/settings_form.html b/vrobbler/templates/profiles/settings_form.html index e261d35..66a74f7 100644 --- a/vrobbler/templates/profiles/settings_form.html +++ b/vrobbler/templates/profiles/settings_form.html @@ -130,6 +130,11 @@ {% endif %} + {% elif field.name == "trends_disabled" %} +

+ {{ field }} + {{ field.label_tag }} +

{% elif field.name == "home_scrobble_limit" %}

{{ field.label_tag }} diff --git a/vrobbler/templates/scrobbles/scrobble_list.html b/vrobbler/templates/scrobbles/scrobble_list.html index 80fe0f2..a7ed0fe 100644 --- a/vrobbler/templates/scrobbles/scrobble_list.html +++ b/vrobbler/templates/scrobbles/scrobble_list.html @@ -76,9 +76,11 @@

+ {% if not user.profile.trends_disabled %} + {% endif %}
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}