diff --git a/vrobbler/apps/charts/__init__.py b/vrobbler/apps/charts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vrobbler/apps/charts/admin.py b/vrobbler/apps/charts/admin.py
new file mode 100644
index 0000000..37f1055
--- /dev/null
+++ b/vrobbler/apps/charts/admin.py
@@ -0,0 +1,50 @@
+from charts.models import ChartRecord
+from django.contrib import admin
+
+
+@admin.register(ChartRecord)
+class ChartRecordAdmin(admin.ModelAdmin):
+ date_hierarchy = "created"
+ list_display = (
+ "user",
+ "rank",
+ "count",
+ "year",
+ "month",
+ "week",
+ "day",
+ "media_name",
+ "media_type",
+ )
+ list_filter = (
+ "user",
+ "year",
+ "month",
+ "week",
+ "day",
+ "tv_series",
+ "podcast",
+ "board_game",
+ "book",
+ "food",
+ "trail",
+ )
+ ordering = ("-created",)
+ search_fields = (
+ "artist__name",
+ "album__name",
+ "track__title",
+ "tv_series__name",
+ "video__title",
+ "podcast__name",
+ "board_game__name",
+ "book__title",
+ "food__name",
+ "trail__name",
+ )
+
+ def media_name(self, obj):
+ return obj.media_obj
+
+ def media_type(self, obj):
+ return obj.media_type
diff --git a/vrobbler/apps/charts/apps.py b/vrobbler/apps/charts/apps.py
new file mode 100644
index 0000000..0fe58cc
--- /dev/null
+++ b/vrobbler/apps/charts/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ChartsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "charts"
diff --git a/vrobbler/apps/charts/management/__init__.py b/vrobbler/apps/charts/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vrobbler/apps/charts/management/commands/__init__.py b/vrobbler/apps/charts/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vrobbler/apps/charts/management/commands/build_charts.py b/vrobbler/apps/charts/management/commands/build_charts.py
new file mode 100644
index 0000000..4ff27fe
--- /dev/null
+++ b/vrobbler/apps/charts/management/commands/build_charts.py
@@ -0,0 +1,59 @@
+import logging
+
+from charts.utils import build_all_historical_charts, rebuild_all_charts
+from django.core.management.base import BaseCommand
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Build missing charts for all users"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--user-id",
+ type=int,
+ help="Build charts for a specific user only",
+ )
+ parser.add_argument(
+ "--full-rebuild",
+ action="store_true",
+ help="Delete existing chart records and rebuild from scratch",
+ )
+
+ def handle(self, *args, **options):
+ from django.contrib.auth import get_user_model
+
+ User = get_user_model()
+ full_rebuild = options["full_rebuild"]
+
+ if full_rebuild:
+ self.stdout.write("Full rebuild requested - will delete existing records")
+
+ if options["user_id"]:
+ user = User.objects.filter(id=options["user_id"]).first()
+ if not user:
+ self.stderr.write(
+ self.style.ERROR(f"User with id {options['user_id']} not found")
+ )
+ return
+ users = [user]
+ action = "Rebuilding" if full_rebuild else "Building charts for"
+ self.stdout.write(f"{action} user: {user}")
+ else:
+ users = User.objects.all()
+ action = "Rebuilding" if full_rebuild else "Building charts for"
+ self.stdout.write(f"{action} charts for {users.count()} users...")
+
+ for user in users:
+ try:
+ logger.info(f"Building all historical charts for {user}")
+ if full_rebuild:
+ rebuild_all_charts(user)
+ else:
+ build_all_historical_charts(user)
+ self.stdout.write(self.style.SUCCESS(f" OK {user}"))
+ except Exception as e:
+ self.stderr.write(f" FAILED {user}: {e}")
+
+ self.stdout.write(self.style.SUCCESS("Done!"))
diff --git a/vrobbler/apps/charts/management/commands/copy_chartrecord_data.py b/vrobbler/apps/charts/management/commands/copy_chartrecord_data.py
new file mode 100644
index 0000000..a8aff61
--- /dev/null
+++ b/vrobbler/apps/charts/management/commands/copy_chartrecord_data.py
@@ -0,0 +1,20 @@
+from django.core.management.base import BaseCommand
+from django.db import connection
+
+
+class Command(BaseCommand):
+ help = "Copy ChartRecord data from scrobbles to charts app"
+
+ def handle(self, *args, **options):
+ with connection.cursor() as cursor:
+ cursor.execute("""
+ INSERT OR IGNORE INTO charts_chartrecord
+ (created, modified, user_id, rank, count, year, month, week, day,
+ video_id, series_id, artist_id, track_id, period_start, period_end)
+ SELECT created, modified, user_id, rank, count, year, month, week, day,
+ video_id, series_id, artist_id, track_id, period_start, period_end
+ FROM scrobbles_chartrecord
+ """)
+ self.stdout.write(
+ self.style.SUCCESS("Copied ChartRecord data to charts app")
+ )
diff --git a/vrobbler/apps/charts/migrations/0001_initial.py b/vrobbler/apps/charts/migrations/0001_initial.py
new file mode 100644
index 0000000..4b476bb
--- /dev/null
+++ b/vrobbler/apps/charts/migrations/0001_initial.py
@@ -0,0 +1,239 @@
+# Generated by Django 4.2.29 on 2026-03-21 19:34
+
+import django.db.models.deletion
+import django_extensions.db.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("boardgames", "0013_boardgame_publishers"),
+ ("locations", "0008_remove_geolocation_run_time_seconds_and_more"),
+ ("podcasts", "0018_remove_podcastepisode_run_time_seconds_and_more"),
+ ("music", "0029_remove_track_run_time_seconds_and_more"),
+ ("videos", "0025_add_imdb_id_unique"),
+ ("trails", "0006_remove_trail_run_time_seconds_and_more"),
+ ("books", "0033_alter_book_issue_number_alter_book_volume_number"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("foods", "0005_food_nutrition_and_sources"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ChartRecord",
+ 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"
+ ),
+ ),
+ ("year", models.IntegerField()),
+ ("month", models.IntegerField(blank=True, null=True)),
+ ("week", models.IntegerField(blank=True, null=True)),
+ ("day", models.IntegerField(blank=True, null=True)),
+ ("rank", models.IntegerField()),
+ ("count", models.IntegerField(default=0)),
+ (
+ "album",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="music.album",
+ ),
+ ),
+ (
+ "artist",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="music.artist",
+ ),
+ ),
+ (
+ "board_game",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="boardgames.boardgame",
+ ),
+ ),
+ (
+ "book",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="books.book",
+ ),
+ ),
+ (
+ "food",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="foods.food",
+ ),
+ ),
+ (
+ "geo_location",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="locations.geolocation",
+ ),
+ ),
+ (
+ "podcast",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="podcasts.podcast",
+ ),
+ ),
+ (
+ "podcast_episode",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="podcasts.podcastepisode",
+ ),
+ ),
+ (
+ "track",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="music.track",
+ ),
+ ),
+ (
+ "trail",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="trails.trail",
+ ),
+ ),
+ (
+ "tv_series",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="videos.series",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "video",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="videos.video",
+ ),
+ ),
+ ],
+ options={
+ "indexes": [
+ models.Index(
+ fields=["user", "year", "artist", "rank"],
+ name="charts_char_user_id_dc09e6_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "album", "rank"],
+ name="charts_char_user_id_218be5_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "track", "rank"],
+ name="charts_char_user_id_db95f8_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "tv_series", "rank"],
+ name="charts_char_user_id_2a16e9_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "video", "rank"],
+ name="charts_char_user_id_717866_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "podcast", "rank"],
+ name="charts_char_user_id_c79e3b_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "board_game", "rank"],
+ name="charts_char_user_id_73fbd2_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "trail", "rank"],
+ name="charts_char_user_id_ad3c30_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "geo_location", "rank"],
+ name="charts_char_user_id_245e01_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "food", "rank"],
+ name="charts_char_user_id_f9fe30_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "book", "rank"],
+ name="charts_char_user_id_e0321e_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "week", "artist", "rank"],
+ name="charts_char_user_id_18f06e_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "week", "tv_series", "rank"],
+ name="charts_char_user_id_8dcc60_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "month", "artist", "rank"],
+ name="charts_char_user_id_3ef8c3_idx",
+ ),
+ models.Index(
+ fields=["user", "year", "month", "tv_series", "rank"],
+ name="charts_char_user_id_dd54d7_idx",
+ ),
+ ],
+ },
+ ),
+ ]
diff --git a/vrobbler/apps/charts/migrations/__init__.py b/vrobbler/apps/charts/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vrobbler/apps/charts/models.py b/vrobbler/apps/charts/models.py
new file mode 100644
index 0000000..2841e3d
--- /dev/null
+++ b/vrobbler/apps/charts/models.py
@@ -0,0 +1,138 @@
+import calendar
+
+from django.contrib.auth import get_user_model
+from django.db import models
+from django.urls import reverse
+from django_extensions.db.models import TimeStampedModel
+
+User = get_user_model()
+BNULL = {"blank": True, "null": True}
+
+
+class ChartRecord(TimeStampedModel):
+ """Stores rankings for a user/media/time period combination.
+
+ Example entries:
+ - Bjork was #3 artist in 2022
+ - Homogenic was #10 album in April 2025
+ - The Office was #1 TV series in Week #34 2024
+ """
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE, **BNULL)
+
+ year = models.IntegerField()
+ month = models.IntegerField(**BNULL)
+ week = models.IntegerField(**BNULL)
+ day = models.IntegerField(**BNULL)
+
+ artist = models.ForeignKey("music.Artist", on_delete=models.CASCADE, **BNULL)
+ album = models.ForeignKey("music.Album", on_delete=models.CASCADE, **BNULL)
+ track = models.ForeignKey("music.Track", on_delete=models.CASCADE, **BNULL)
+ tv_series = models.ForeignKey("videos.Series", on_delete=models.CASCADE, **BNULL)
+ video = models.ForeignKey("videos.Video", on_delete=models.CASCADE, **BNULL)
+ podcast = models.ForeignKey("podcasts.Podcast", on_delete=models.CASCADE, **BNULL)
+ podcast_episode = models.ForeignKey(
+ "podcasts.PodcastEpisode", on_delete=models.CASCADE, **BNULL
+ )
+ board_game = models.ForeignKey(
+ "boardgames.BoardGame", on_delete=models.CASCADE, **BNULL
+ )
+ trail = models.ForeignKey("trails.Trail", on_delete=models.CASCADE, **BNULL)
+ geo_location = models.ForeignKey(
+ "locations.GeoLocation", on_delete=models.CASCADE, **BNULL
+ )
+ food = models.ForeignKey("foods.Food", on_delete=models.CASCADE, **BNULL)
+ book = models.ForeignKey("books.Book", on_delete=models.CASCADE, **BNULL)
+
+ rank = models.IntegerField()
+ count = models.IntegerField(default=0)
+
+ class Meta:
+ indexes = [
+ models.Index(fields=["user", "year", "artist", "rank"]),
+ models.Index(fields=["user", "year", "album", "rank"]),
+ models.Index(fields=["user", "year", "track", "rank"]),
+ models.Index(fields=["user", "year", "tv_series", "rank"]),
+ models.Index(fields=["user", "year", "video", "rank"]),
+ models.Index(fields=["user", "year", "podcast", "rank"]),
+ models.Index(fields=["user", "year", "board_game", "rank"]),
+ models.Index(fields=["user", "year", "trail", "rank"]),
+ models.Index(fields=["user", "year", "geo_location", "rank"]),
+ models.Index(fields=["user", "year", "food", "rank"]),
+ models.Index(fields=["user", "year", "book", "rank"]),
+ models.Index(fields=["user", "year", "week", "artist", "rank"]),
+ models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
+ models.Index(fields=["user", "year", "month", "artist", "rank"]),
+ models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
+ ]
+
+ @property
+ def media_obj(self):
+ for attr in [
+ "artist",
+ "album",
+ "track",
+ "tv_series",
+ "video",
+ "podcast",
+ "podcast_episode",
+ "board_game",
+ "trail",
+ "geo_location",
+ "food",
+ "book",
+ ]:
+ obj = getattr(self, attr)
+ if obj:
+ return obj
+ return None
+
+ @property
+ def media_type(self):
+ mapping = {
+ "artist": "Artist",
+ "album": "Album",
+ "track": "Track",
+ "tv_series": "TV Series",
+ "video": "Video",
+ "podcast": "Podcast",
+ "podcast_episode": "Podcast Episode",
+ "board_game": "Board Game",
+ "trail": "Trail",
+ "geo_location": "Location",
+ "food": "Food",
+ "book": "Book",
+ }
+ for attr, name in mapping.items():
+ if getattr(self, attr) is not None:
+ return name
+ return None
+
+ @property
+ def period_str(self) -> str:
+ period = str(self.year)
+ if self.month:
+ period = f"{calendar.month_name[self.month]} {period}"
+ if self.week:
+ period = f"Week {self.week}, {period}"
+ if self.day:
+ period = f"{calendar.month_name[self.month]} {self.day}, {period}"
+ return period
+
+ @property
+ def period_type(self) -> str:
+ if self.day:
+ return "day"
+ if self.week:
+ return "week"
+ if self.month:
+ return "month"
+ return "year"
+
+ def __str__(self):
+ obj = self.media_obj
+ obj_name = str(obj) if obj else "Unknown"
+ return f"#{self.rank} {obj_name} in {self.period_str}"
+
+ def get_absolute_url(self):
+ return reverse("charts:charts-home")
diff --git a/vrobbler/apps/charts/templates/charts/chart_index.html b/vrobbler/apps/charts/templates/charts/chart_index.html
new file mode 100644
index 0000000..b4ffdc0
--- /dev/null
+++ b/vrobbler/apps/charts/templates/charts/chart_index.html
@@ -0,0 +1,315 @@
+{% extends "base_list.html" %}
+{% load chart_tags %}
+
+{% block title %}Charts{% endblock %}
+
+{% block head_extra %}
+
+{% endblock %}
+
+{% block lists %}
+{% if chart_type == "maloja" %}
+{% include "scrobbles/_top_charts.html" %}
+{% else %}
+
+ {% if charts.artist %}
+
+
🎤 Top Artists
+
+
+
+
+ | Rank |
+ Artist |
+ Scrobbles |
+
+
+
+ {% for chart in charts.artist %}
+
+ | {{chart.rank}} |
+ {{chart.artist.name}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.track %}
+
+
🎵 Top Tracks
+
+
+
+
+ | Rank |
+ Track |
+ Scrobbles |
+
+
+
+ {% for chart in charts.track %}
+
+ | {{chart.rank}} |
+ {{chart.track.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.tv_series %}
+
+
📺 Top TV Shows
+
+
+
+
+ | Rank |
+ TV Show |
+ Episodes Watched |
+
+
+
+ {% for chart in charts.tv_series %}
+
+ | {{chart.rank}} |
+ {{chart.tv_series.name}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.album %}
+
+
💿 Top Albums
+
+
+
+
+ | Rank |
+ Album |
+ Scrobbles |
+
+
+
+ {% for chart in charts.album %}
+
+ | {{chart.rank}} |
+ {{chart.album.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.video %}
+
+
🎬 Top Videos
+
+
+
+
+ | Rank |
+ Video |
+ Plays |
+
+
+
+ {% for chart in charts.video %}
+
+ | {{chart.rank}} |
+ {{chart.video.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.board_game %}
+
+
🎲 Top Board Games
+
+
+
+
+ | Rank |
+ Board Game |
+ Plays |
+
+
+
+ {% for chart in charts.board_game %}
+
+ | {{chart.rank}} |
+ {{chart.board_game.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.book %}
+
+
📚 Top Books
+
+
+
+
+ | Rank |
+ Book |
+ Pages Read |
+
+
+
+ {% for chart in charts.book %}
+
+ | {{chart.rank}} |
+ {{chart.book.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.food %}
+
+
🍽️ Top Foods
+
+
+
+
+ | Rank |
+ Food |
+ Times Eaten |
+
+
+
+ {% for chart in charts.food %}
+
+ | {{chart.rank}} |
+ {{chart.food.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.podcast %}
+
+
🎙️ Top Podcasts
+
+
+
+
+ | Rank |
+ Podcast |
+ Episodes |
+
+
+
+ {% for chart in charts.podcast %}
+
+ | {{chart.rank}} |
+ {{chart.podcast.title}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+ {% if charts.trail %}
+
+
🥾 Top Trails
+
+
+
+
+ | Rank |
+ Trail |
+ Hikes |
+
+
+
+ {% for chart in charts.trail %}
+
+ | {{chart.rank}} |
+ {{chart.trail.name}} |
+ {{chart.count}} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+{% endif %}
+{% endblock %}
diff --git a/vrobbler/apps/charts/templatetags/__init__.py b/vrobbler/apps/charts/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vrobbler/apps/charts/templatetags/chart_tags.py b/vrobbler/apps/charts/templatetags/chart_tags.py
new file mode 100644
index 0000000..51ec232
--- /dev/null
+++ b/vrobbler/apps/charts/templatetags/chart_tags.py
@@ -0,0 +1,10 @@
+from django import template
+
+register = template.Library()
+
+
+@register.filter
+def get_item(dictionary, key):
+ if isinstance(dictionary, dict):
+ return dictionary.get(key)
+ return None
diff --git a/vrobbler/apps/charts/urls.py b/vrobbler/apps/charts/urls.py
new file mode 100644
index 0000000..eb8f21f
--- /dev/null
+++ b/vrobbler/apps/charts/urls.py
@@ -0,0 +1,8 @@
+from charts.views import ChartRecordView
+from django.urls import path
+
+app_name = "charts"
+
+urlpatterns = [
+ path("charts/", ChartRecordView.as_view(), name="charts-home"),
+]
diff --git a/vrobbler/apps/charts/utils.py b/vrobbler/apps/charts/utils.py
new file mode 100644
index 0000000..99856c2
--- /dev/null
+++ b/vrobbler/apps/charts/utils.py
@@ -0,0 +1,339 @@
+import calendar
+import datetime
+import logging
+from typing import Optional
+
+import pytz
+from django.apps import apps
+from django.conf import settings
+from django.db.models import Count, Q
+from django.utils import timezone
+
+logger = logging.getLogger(__name__)
+
+
+def get_time_filter(
+ year: Optional[int],
+ month: Optional[int],
+ week: Optional[int],
+ day: Optional[int],
+ user,
+):
+ """Build a Q filter for scrobble timestamps based on time period."""
+ if not year:
+ return Q()
+
+ if user and user.is_authenticated:
+ tz = pytz.timezone(user.profile.timezone)
+ else:
+ tz = pytz.timezone(settings.TIME_ZONE)
+
+ if day and month:
+ start = timezone.make_aware(
+ datetime.datetime(year, month, day, 0, 0, 0)
+ )
+ end = timezone.make_aware(
+ datetime.datetime(year, month, day, 23, 59, 59)
+ )
+ elif week:
+ period = datetime.date.fromisocalendar(year, week, 1)
+ start = timezone.make_aware(
+ datetime.datetime.combine(period, datetime.time.min)
+ )
+ end = start + datetime.timedelta(days=6, seconds=86399)
+ elif month:
+ last_day = calendar.monthrange(year, month)[1]
+ start = timezone.make_aware(datetime.datetime(year, month, 1))
+ end = timezone.make_aware(
+ datetime.datetime(year, month, last_day, 23, 59, 59)
+ )
+ else:
+ start = timezone.make_aware(datetime.datetime(year, 1, 1))
+ end = timezone.make_aware(datetime.datetime(year, 12, 31, 23, 59, 59))
+
+ return Q(timestamp__gte=start, timestamp__lte=end)
+
+
+def build_charts(
+ user,
+ year: Optional[int] = None,
+ month: Optional[int] = None,
+ week: Optional[int] = None,
+ day: Optional[int] = None,
+ media_types: Optional[list] = None,
+):
+ """Build chart records for all or specified media types."""
+ ChartRecord = apps.get_model("charts", "ChartRecord")
+ Scrobble = apps.get_model("scrobbles", "Scrobble")
+
+ if media_types is None:
+ media_types = [
+ "artist",
+ "album",
+ "track",
+ "tv_series",
+ "video",
+ "podcast",
+ "podcast_episode",
+ "board_game",
+ "trail",
+ "geo_location",
+ "food",
+ "book",
+ ]
+
+ period_filter = Q(year=year)
+ if month:
+ period_filter &= Q(month=month)
+ if week:
+ period_filter &= Q(week=week)
+ if day:
+ period_filter &= Q(day=day)
+
+ ChartRecord.objects.filter(period_filter, user=user).delete()
+
+ time_filter = get_time_filter(year, month, week, day, user)
+ base_qs = Scrobble.objects.filter(user=user, played_to_completion=True)
+
+ if time_filter:
+ base_qs = base_qs.filter(time_filter)
+
+ media_config = {
+ "artist": {
+ "filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
+ "values": "track__artist",
+ "annotate": Count("id", distinct=True),
+ },
+ "album": {
+ "filter": Q(track__isnull=False) & Q(track__album__isnull=False),
+ "values": "track__album",
+ "annotate": Count("id", distinct=True),
+ },
+ "track": {
+ "filter": Q(track__isnull=False),
+ "values": "track",
+ "annotate": Count("id", distinct=True),
+ },
+ "tv_series": {
+ "filter": Q(video__isnull=False)
+ & Q(video__tv_series__isnull=False),
+ "values": "video__tv_series",
+ "annotate": Count("id", distinct=True),
+ },
+ "video": {
+ "filter": Q(video__isnull=False),
+ "values": "video",
+ "annotate": Count("id", distinct=True),
+ },
+ "podcast": {
+ "filter": Q(podcast_episode__isnull=False)
+ & Q(podcast_episode__podcast__isnull=False),
+ "values": "podcast_episode__podcast",
+ "annotate": Count("id", distinct=True),
+ },
+ "podcast_episode": {
+ "filter": Q(podcast_episode__isnull=False),
+ "values": "podcast_episode",
+ "annotate": Count("id", distinct=True),
+ },
+ "board_game": {
+ "filter": Q(board_game__isnull=False),
+ "values": "board_game",
+ "annotate": Count("id", distinct=True),
+ },
+ "trail": {
+ "filter": Q(trail__isnull=False),
+ "values": "trail",
+ "annotate": Count("id", distinct=True),
+ },
+ "geo_location": {
+ "filter": Q(geo_location__isnull=False),
+ "values": "geo_location",
+ "annotate": Count("id", distinct=True),
+ },
+ "food": {
+ "filter": Q(food__isnull=False),
+ "values": "food",
+ "annotate": Count("id", distinct=True),
+ },
+ "book": {
+ "filter": Q(book__isnull=False),
+ "values": "book",
+ "annotate": Count("id", distinct=True),
+ },
+ }
+
+ BATCH_SIZE = 500
+
+ for media_type in media_types:
+ if media_type not in media_config:
+ continue
+
+ config = media_config[media_type]
+
+ results = list(
+ base_qs.filter(config["filter"])
+ .values(config["values"])
+ .annotate(scrobble_count=config["annotate"])
+ .order_by("-scrobble_count")
+ )
+
+ if not results:
+ continue
+
+ unique_counts = sorted(
+ set(r["scrobble_count"] for r in results), reverse=True
+ )
+ ranks = {
+ count: rank for rank, count in enumerate(unique_counts, start=1)
+ }
+
+ chart_records = []
+ total_created = 0
+ for result in results:
+ chart_record_data = {
+ "user_id": user.id,
+ "year": year,
+ "month": month,
+ "week": week,
+ "day": day,
+ "rank": ranks[result["scrobble_count"]],
+ "count": result["scrobble_count"],
+ }
+ chart_record_data[f"{media_type}_id"] = result[config["values"]]
+ chart_records.append(ChartRecord(**chart_record_data))
+
+ if len(chart_records) >= BATCH_SIZE:
+ ChartRecord.objects.bulk_create(
+ chart_records, batch_size=BATCH_SIZE
+ )
+ total_created += len(chart_records)
+ chart_records = []
+
+ if chart_records:
+ ChartRecord.objects.bulk_create(
+ chart_records, batch_size=BATCH_SIZE
+ )
+ total_created += len(chart_records)
+
+ logger.info(
+ f"Built {total_created} chart records for {media_type}, period "
+ f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
+ )
+
+
+def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
+ """Build charts for yesterday's date."""
+ tz = pytz.timezone(settings.TIME_ZONE)
+ if user and user.is_authenticated:
+ tz = pytz.timezone(user.profile.timezone)
+
+ now = timezone.now().astimezone(tz)
+ yesterday = now - datetime.timedelta(days=1)
+
+ logger.info(f"Building charts for {yesterday.date()} for {user}")
+
+ build_charts(
+ user=user,
+ year=yesterday.year,
+ month=yesterday.month,
+ day=yesterday.day,
+ media_types=media_types,
+ )
+
+
+def build_weekly_charts(
+ user, year: int, week: int, media_types: Optional[list] = None
+) -> None:
+ """Build weekly charts for a specific ISO week."""
+ build_charts(
+ user=user,
+ year=year,
+ week=week,
+ media_types=media_types,
+ )
+
+
+def build_monthly_charts(
+ user, year: int, month: int, media_types: Optional[list] = None
+) -> None:
+ """Build monthly charts for a specific month."""
+ build_charts(
+ user=user,
+ year=year,
+ month=month,
+ media_types=media_types,
+ )
+
+
+def build_yearly_charts(
+ user, year: int, media_types: Optional[list] = None
+) -> None:
+ """Build yearly charts for a specific year."""
+ build_charts(
+ user=user,
+ year=year,
+ media_types=media_types,
+ )
+
+
+def build_all_historical_charts(
+ user, media_types: Optional[list] = None
+) -> None:
+ """Build all historical charts for a user."""
+ Scrobble = apps.get_model("scrobbles", "Scrobble")
+
+ first_scrobble = (
+ Scrobble.objects.filter(user=user, played_to_completion=True)
+ .order_by("timestamp")
+ .first()
+ )
+
+ if not first_scrobble:
+ logger.info(f"No scrobbles found for {user}")
+ return
+
+ now = timezone.now()
+ current = first_scrobble.timestamp.date()
+ last_year = None
+ last_week = None
+
+ while current <= now.date():
+ year = current.year
+ month = current.month
+ day = current.day
+ week = current.isocalendar()[1]
+
+ build_monthly_charts(user, year, month, media_types)
+ build_daily_charts(user, year, month, day, media_types)
+
+ if last_week != week:
+ build_weekly_charts(user, year, week, media_types)
+ last_week = week
+
+ if last_year != year:
+ build_yearly_charts(user, year, media_types)
+ last_year = year
+
+ current += datetime.timedelta(days=1)
+
+
+def build_daily_charts(
+ user, year: int, month: int, day: int, media_types: Optional[list] = None
+) -> None:
+ """Build daily charts for a specific day."""
+ build_charts(
+ user=user,
+ year=year,
+ month=month,
+ day=day,
+ media_types=media_types,
+ )
+
+
+def rebuild_all_charts(user, media_types: Optional[list] = None) -> None:
+ """Delete all existing chart records for a user and rebuild them."""
+ ChartRecord = apps.get_model("charts", "ChartRecord")
+ deleted_count = ChartRecord.objects.filter(user=user).delete()[0]
+ logger.info(f"Deleted {deleted_count} existing chart records for {user}")
+ build_all_historical_charts(user, media_types)
diff --git a/vrobbler/apps/charts/views.py b/vrobbler/apps/charts/views.py
new file mode 100644
index 0000000..812955a
--- /dev/null
+++ b/vrobbler/apps/charts/views.py
@@ -0,0 +1,417 @@
+import calendar
+from datetime import timedelta
+
+from charts.models import ChartRecord
+from django.db.models import Q
+from django.utils import timezone
+from django.views.generic import TemplateView
+from profiles.utils import now_user_timezone
+
+MEDIA_TYPE_FILTERS = {
+ "artist": Q(artist__isnull=False),
+ "album": Q(album__isnull=False),
+ "track": Q(track__isnull=False),
+ "tv_series": Q(tv_series__isnull=False),
+ "video": Q(video__isnull=False),
+ "podcast": Q(podcast__isnull=False),
+ "podcast_episode": Q(podcast_episode__isnull=False),
+ "board_game": Q(board_game__isnull=False),
+ "trail": Q(trail__isnull=False),
+ "geo_location": Q(geo_location__isnull=False),
+ "food": Q(food__isnull=False),
+ "book": Q(book__isnull=False),
+}
+
+
+class ChartRecordView(TemplateView):
+ template_name = "charts/chart_index.html"
+
+ def get_charts(
+ self,
+ user,
+ media_type: str,
+ year=None,
+ month=None,
+ week=None,
+ day=None,
+ limit=20,
+ ):
+ params = {"user": user}
+ if year is not None:
+ params["year"] = year
+ if month is not None:
+ params["month"] = month
+ if week is not None:
+ params["week"] = week
+ if day is not None:
+ params["day"] = day
+
+ filter_key = media_type.lower()
+ media_filter = MEDIA_TYPE_FILTERS.get(filter_key, Q())
+
+ return ChartRecord.objects.filter(media_filter, **params).order_by("rank")[
+ :limit
+ ]
+
+ def get_charts_for_period(
+ self,
+ user,
+ media_type: str,
+ year=None,
+ month=None,
+ week=None,
+ day=None,
+ limit=20,
+ ):
+ """Get charts for a specific period, properly filtering by period type."""
+ params = {"user": user}
+ if year is not None:
+ params["year"] = year
+ if month is not None:
+ params["month"] = month
+ if week is not None:
+ params["week"] = week
+ if day is not None:
+ params["day"] = day
+
+ filter_key = media_type.lower()
+ media_filter = MEDIA_TYPE_FILTERS.get(filter_key, Q())
+
+ qs = ChartRecord.objects.filter(media_filter, **params)
+
+ if day is not None:
+ qs = qs.filter(day__isnull=False)
+ elif week is not None:
+ qs = qs.filter(week__isnull=False, day__isnull=True)
+ elif month is not None:
+ qs = qs.filter(month__isnull=False, week__isnull=True, day__isnull=True)
+ else:
+ qs = qs.filter(month__isnull=True, week__isnull=True, day__isnull=True)
+
+ return qs.order_by("rank")[:limit]
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ user = self.request.user
+ chart_type = self.request.GET.get("chart_type", "standard")
+ context["chart_type"] = chart_type
+
+ date_param = self.request.GET.get("date")
+
+ now = timezone.now()
+ if user.is_authenticated:
+ now = now_user_timezone(user.profile)
+ today = now.date()
+ current_year = today.year
+ current_month = today.month
+ current_week = today.isocalendar()[1]
+ current_day = today.day
+
+ context["current_year"] = current_year
+ context["current_month"] = current_month
+ context["current_week"] = current_week
+ context["current_day"] = current_day
+
+ if chart_type == "maloja":
+ context["chart_keys"] = {
+ "today": "Today",
+ "week": "This Week",
+ "month": "This Month",
+ "year": "This Year",
+ "all": "All Time",
+ }
+
+ context["maloja_charts"] = {
+ "artist": {
+ "today": list(
+ self.get_charts_for_period(
+ user,
+ "artist",
+ year=current_year,
+ month=current_month,
+ day=current_day,
+ )
+ ),
+ "week": list(
+ self.get_charts_for_period(
+ user,
+ "artist",
+ year=current_year,
+ week=current_week,
+ )
+ ),
+ "month": list(
+ self.get_charts_for_period(
+ user,
+ "artist",
+ year=current_year,
+ month=current_month,
+ )
+ ),
+ "year": list(
+ self.get_charts_for_period(user, "artist", year=current_year)
+ ),
+ "all": list(self.get_charts_for_period(user, "artist")),
+ },
+ "track": {
+ "today": list(
+ self.get_charts_for_period(
+ user,
+ "track",
+ year=current_year,
+ month=current_month,
+ day=current_day,
+ )
+ ),
+ "week": list(
+ self.get_charts_for_period(
+ user, "track", year=current_year, week=current_week
+ )
+ ),
+ "month": list(
+ self.get_charts_for_period(
+ user,
+ "track",
+ year=current_year,
+ month=current_month,
+ )
+ ),
+ "year": list(
+ self.get_charts_for_period(user, "track", year=current_year)
+ ),
+ "all": list(self.get_charts_for_period(user, "track")),
+ },
+ "tv_series": {
+ "today": list(
+ self.get_charts_for_period(
+ user,
+ "tv_series",
+ year=current_year,
+ month=current_month,
+ day=current_day,
+ )
+ ),
+ "week": list(
+ self.get_charts_for_period(
+ user,
+ "tv_series",
+ year=current_year,
+ week=current_week,
+ )
+ ),
+ "month": list(
+ self.get_charts_for_period(
+ user,
+ "tv_series",
+ year=current_year,
+ month=current_month,
+ )
+ ),
+ "year": list(
+ self.get_charts_for_period(user, "tv_series", year=current_year)
+ ),
+ "all": list(self.get_charts_for_period(user, "tv_series")),
+ },
+ }
+ return context
+
+ if not date_param:
+ context["period"] = "current"
+ context["year"] = current_year
+ context["month"] = current_month
+ context["week"] = current_week
+ context["day"] = current_day
+
+ context["charts"] = {
+ "artist": list(
+ self.get_charts_for_period(
+ user, "artist", year=current_year, limit=20
+ )
+ ),
+ "album": list(
+ self.get_charts_for_period(
+ user, "album", year=current_year, limit=20
+ )
+ ),
+ "track": list(
+ self.get_charts_for_period(
+ user, "track", year=current_year, limit=20
+ )
+ ),
+ "tv_series": list(
+ self.get_charts_for_period(
+ user, "tv_series", year=current_year, limit=20
+ )
+ ),
+ "video": list(
+ self.get_charts_for_period(
+ user, "video", year=current_year, limit=20
+ )
+ ),
+ "board_game": list(
+ self.get_charts_for_period(
+ user, "board_game", year=current_year, limit=20
+ )
+ ),
+ "book": list(
+ self.get_charts_for_period(
+ user, "book", year=current_year, limit=20
+ )
+ ),
+ "food": list(
+ self.get_charts_for_period(
+ user, "food", year=current_year, limit=20
+ )
+ ),
+ "podcast": list(
+ self.get_charts_for_period(
+ user, "podcast", year=current_year, limit=20
+ )
+ ),
+ "trail": list(
+ self.get_charts_for_period(
+ user, "trail", year=current_year, limit=20
+ )
+ ),
+ }
+ else:
+ parts = date_param.split("-")
+ year = int(parts[0])
+
+ week = None
+ month = None
+ day = None
+
+ if len(parts) >= 2 and parts[1].startswith("W"):
+ week = int(parts[1].lstrip("W"))
+ elif len(parts) >= 2 and parts[1]:
+ try:
+ month = int(parts[1])
+ except ValueError:
+ pass
+
+ if len(parts) >= 3:
+ if parts[2].startswith("W"):
+ week = int(parts[2].lstrip("W"))
+ elif not parts[2].startswith("W"):
+ day = int(parts[2])
+
+ context["period"] = "historical"
+ context["year"] = year
+ context["month"] = month
+ context["week"] = week
+ context["day"] = day
+
+ period_str = str(year)
+ if month:
+ period_str = f"{calendar.month_name[month]} {period_str}"
+ if week:
+ period_str = f"Week {week}, {period_str}"
+ if day:
+ period_str = f"{calendar.month_name[month]} {day}, {year}"
+ context["period_str"] = period_str
+
+ context["charts"] = {
+ "artist": list(
+ self.get_charts_for_period(
+ user,
+ "artist",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "album": list(
+ self.get_charts_for_period(
+ user,
+ "album",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "track": list(
+ self.get_charts_for_period(
+ user,
+ "track",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "tv_series": list(
+ self.get_charts_for_period(
+ user,
+ "tv_series",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "video": list(
+ self.get_charts_for_period(
+ user,
+ "video",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "board_game": list(
+ self.get_charts_for_period(
+ user,
+ "board_game",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "book": list(
+ self.get_charts_for_period(
+ user,
+ "book",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "food": list(
+ self.get_charts_for_period(
+ user,
+ "food",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "podcast": list(
+ self.get_charts_for_period(
+ user,
+ "podcast",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ "trail": list(
+ self.get_charts_for_period(
+ user,
+ "trail",
+ year=year,
+ month=month,
+ week=week,
+ day=day,
+ )
+ ),
+ }
+
+ return context
diff --git a/vrobbler/apps/music/views.py b/vrobbler/apps/music/views.py
index 8e3bc04..1be2e1a 100644
--- a/vrobbler/apps/music/views.py
+++ b/vrobbler/apps/music/views.py
@@ -1,7 +1,7 @@
from django.db.models import Count
from django.views import generic
from music.models import Album, Artist, Track
-from scrobbles.models import ChartRecord
+from charts.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
@@ -35,9 +35,7 @@ class ArtistListView(generic.ListView):
)
def get_context_data(self, *, object_list=None, **kwargs):
- context_data = super().get_context_data(
- object_list=object_list, **kwargs
- )
+ context_data = super().get_context_data(object_list=object_list, **kwargs)
context_data["view"] = self.request.GET.get("view")
return context_data
diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py
index db22c1a..8991ce8 100644
--- a/vrobbler/apps/scrobbles/admin.py
+++ b/vrobbler/apps/scrobbles/admin.py
@@ -2,7 +2,6 @@ from django.contrib import admin
from scrobbles.models import (
AudioScrobblerTSVImport,
- ChartRecord,
KoReaderImport,
LastFmImport,
RetroarchImport,
@@ -71,6 +70,7 @@ class KoReaderImportAdmin(ImportBaseAdmin):
@admin.register(RetroarchImport)
class RetroarchImportAdmin(ImportBaseAdmin):
...
+class RetroarchImportAdmin(ImportBaseAdmin): ...
@admin.register(Genre)
@@ -81,25 +81,6 @@ class GenreAdmin(admin.ModelAdmin):
)
-@admin.register(ChartRecord)
-class ChartRecordAdmin(admin.ModelAdmin):
- date_hierarchy = "created"
- list_display = (
- "user",
- "rank",
- "count",
- "year",
- "week",
- "month",
- "day",
- "media_name",
- )
- ordering = ("-created",)
-
- def media_name(self, obj):
- return obj.media_obj
-
-
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp"
diff --git a/vrobbler/apps/scrobbles/migrations/0073_delete_chartrecord.py b/vrobbler/apps/scrobbles/migrations/0073_delete_chartrecord.py
new file mode 100644
index 0000000..72ab6cd
--- /dev/null
+++ b/vrobbler/apps/scrobbles/migrations/0073_delete_chartrecord.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.2.29 on 2026-03-21 20:15
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("scrobbles", "0072_add_scrobble_indexes"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="ChartRecord",
+ ),
+ ]
diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py
index 6dd57d3..11fadc4 100644
--- a/vrobbler/apps/scrobbles/models.py
+++ b/vrobbler/apps/scrobbles/models.py
@@ -48,7 +48,7 @@ from scrobbles.constants import (
)
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import ScrobbleNtfyNotification
-from scrobbles.stats import build_charts
+from charts.utils import build_charts
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent
from tasks.models import Task
@@ -344,140 +344,6 @@ class RetroarchImport(BaseFileImportMixin):
self.mark_finished()
-class ChartRecord(TimeStampedModel):
- """Sort of like a materialized view for what we could dynamically generate,
- but would kill the DB as it gets larger. Collects time-based records
- generated by a cron-like archival job
-
- 1972 by Josh Rouse - #3 in 2023, January
-
- """
-
- user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
- rank = models.IntegerField(db_index=True)
- count = models.IntegerField(default=0)
- year = models.IntegerField(**BNULL)
- month = models.IntegerField(**BNULL)
- week = models.IntegerField(**BNULL)
- day = models.IntegerField(**BNULL)
- video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
- series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
- artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
- track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
- period_start = models.DateTimeField(**BNULL)
- period_end = models.DateTimeField(**BNULL)
-
- def save(self, *args, **kwargs):
- profile = self.user.profile
-
- if self.week:
- # set start and end to start and end of week
- period = datetime.date.fromisocalendar(self.year, self.week, 1)
- self.period_start = start_of_week(period, profile)
- self.period_start = end_of_week(period, profile)
- if self.day:
- period = datetime.datetime(self.year, self.month, self.day)
- self.period_start = start_of_day(period, profile)
- self.period_end = end_of_day(period, profile)
- if self.month and not self.day:
- period = datetime.datetime(self.year, self.month, 1)
- self.period_start = start_of_month(period, profile)
- self.period_end = end_of_month(period, profile)
- super(ChartRecord, self).save(*args, **kwargs)
-
- @property
- def media_obj(self):
- media_obj = None
- if self.video:
- media_obj = self.video
- if self.track:
- media_obj = self.track
- if self.artist:
- media_obj = self.artist
- return media_obj
-
- @property
- def month_str(self) -> str:
- month_str = ""
- if self.month:
- month_str = calendar.month_name[self.month]
- return month_str
-
- @property
- def day_str(self) -> str:
- day_str = ""
- if self.day:
- day_str = str(self.day)
- return day_str
-
- @property
- def week_str(self) -> str:
- week_str = ""
- if self.week:
- week_str = str(self.week)
- return "Week " + week_str
-
- @property
- def period(self) -> str:
- period = str(self.year)
- if self.month:
- period = " ".join([self.month_str, period])
- if self.week:
- period = " ".join([self.week_str, period])
- if self.day:
- period = " ".join([self.day_str, period])
- return period
-
- @property
- def period_type(self) -> str:
- period = "year"
- if self.month:
- period = "month"
- if self.week:
- period = "week"
- if self.day:
- period = "day"
- return period
-
- def __str__(self):
- title = f"#{self.rank} in {self.period}"
- if self.day or self.week:
- title = f"#{self.rank} on {self.period}"
- return title
-
- def link(self):
- get_params = f"?date={self.year}"
- if self.week:
- get_params = get_params = get_params + f"-W{self.week}"
- if self.month:
- get_params = get_params = get_params + f"-{self.month}"
- if self.day:
- get_params = get_params = get_params + f"-{self.day}"
- if self.artist:
- get_params = get_params + "&media=Artist"
- return reverse("scrobbles:charts-home") + get_params
-
- @classmethod
- def build(cls, user, **kwargs):
- build_charts(user=user, **kwargs)
-
- @classmethod
- def for_year(cls, user, year):
- return cls.objects.filter(year=year, user=user)
-
- @classmethod
- def for_month(cls, user, year, month):
- return cls.objects.filter(year=year, month=month, user=user)
-
- @classmethod
- def for_day(cls, user, year, day, month):
- return cls.objects.filter(year=year, month=month, day=day, user=user)
-
- @classmethod
- def for_week(cls, user, year, week):
- return cls.objects.filter(year=year, week=week, user=user)
-
-
class ScrobbleQuerySet(models.QuerySet):
def with_related(self):
return self.select_related("user").prefetch_related(
diff --git a/vrobbler/apps/scrobbles/stats.py b/vrobbler/apps/scrobbles/stats.py
index a95913d..71b6b08 100644
--- a/vrobbler/apps/scrobbles/stats.py
+++ b/vrobbler/apps/scrobbles/stats.py
@@ -44,13 +44,9 @@ def get_scrobble_count_qs(
if model_str == "Video":
data_model = apps.get_model(app_label="videos", model_name="Video")
if model_str == "SportEvent":
- data_model = apps.get_model(
- app_label="sports", model_name="SportEvent"
- )
+ data_model = apps.get_model(app_label="sports", model_name="SportEvent")
if model_str == "GeoLocation":
- data_model = apps.get_model(
- app_label="locations", model_name="GeoLocation"
- )
+ data_model = apps.get_model(app_label="locations", model_name="GeoLocation")
if model_str == "Artist":
base_qs = data_model.objects.filter(
@@ -106,135 +102,3 @@ def get_scrobble_count_qs(
)
return qs
-
-
-def build_charts(
- user: "User",
- year: Optional[int] = None,
- month: Optional[int] = None,
- week: Optional[int] = None,
- day: Optional[int] = None,
- model_str="Track",
-):
- ChartRecord = apps.get_model(
- app_label="scrobbles", model_name="ChartRecord"
- )
- results = get_scrobble_count_qs(year, month, week, day, user, model_str)
- unique_counts = list(set([result.scrobble_count for result in results]))
- unique_counts.sort(reverse=True)
- ranks = {}
- for rank, count in enumerate(unique_counts, start=1):
- ranks[count] = rank
-
- chart_records = []
- for result in results:
- chart_record = {
- "year": year,
- "week": week,
- "month": month,
- "day": day,
- "user": user,
- }
- chart_record["rank"] = ranks[result.scrobble_count]
- chart_record["count"] = result.scrobble_count
- if model_str == "Track":
- chart_record["track"] = result
- if model_str == "Video":
- chart_record["video"] = result
- if model_str == "Artist":
- chart_record["artist"] = result
- chart_records.append(ChartRecord(**chart_record))
- ChartRecord.objects.bulk_create(
- chart_records, ignore_conflicts=True, batch_size=500
- )
-
-
-def build_yesterdays_charts_for_user(user: "User", model_str="Track") -> None:
- """Given a user calculate needed charts."""
- ChartRecord = apps.get_model(
- app_label="scrobbles", model_name="ChartRecord"
- )
- tz = pytz.timezone(settings.TIME_ZONE)
- if user and user.is_authenticated:
- tz = pytz.timezone(user.profile.timezone)
- now = timezone.now().astimezone(tz)
- yesterday = now - timedelta(days=1)
- logger.info(
- f"Generating charts for yesterday ({yesterday.date()}) for {user}"
- )
-
- # Always build yesterday's chart
- ChartRecord.build(
- user,
- year=yesterday.year,
- month=yesterday.month,
- day=yesterday.day,
- model_str=model_str,
- )
- now_week = now.isocalendar()[1]
- yesterday_week = now.isocalendar()[1]
- if now_week != yesterday_week:
- logger.info(
- f"New weekly charts for {yesterday.year}-{yesterday_week} for {user}"
- )
- ChartRecord.build(
- user,
- year=yesterday.year,
- month=yesterday_week,
- model_str=model_str,
- )
- # If the month has changed, build charts
- if now.month != yesterday.month:
- logger.info(
- f"New monthly charts for {yesterday.year}-{yesterday.month} for {user}"
- )
- ChartRecord.build(
- user,
- year=yesterday.year,
- month=yesterday.month,
- model_str=model_str,
- )
- # If the year has changed, build charts
- if now.year != yesterday.year:
- logger.info(f"New annual charts for {yesterday.year} for {user}")
- ChartRecord.build(user, year=yesterday.year, model_str=model_str)
-
-
-def build_missing_charts_for_user(user: "User", model_str="Track") -> None:
- """"""
- ChartRecord = apps.get_model(
- app_label="scrobbles", model_name="ChartRecord"
- )
- Scrobble = apps.get_model(app_label="scrobbles", model_name="Scrobble")
-
- logger.info(f"Generating historical charts for {user}")
- tz = pytz.timezone(settings.TIME_ZONE)
- if user and user.is_authenticated:
- tz = pytz.timezone(user.profile.timezone)
- now = timezone.now().astimezone(tz)
-
- first_scrobble = (
- Scrobble.objects.filter(user=user, played_to_completion=True)
- .order_by("created")
- .first()
- )
-
- start_date = first_scrobble.timestamp
- days_since = (now - start_date).days
-
- for day_num in range(0, days_since):
- build_date = start_date + timedelta(days=day_num)
- logger.info(f"Generating chart batch for {build_date}")
- ChartRecord.build(user=user, year=build_date.year)
- ChartRecord.build(
- user=user, year=build_date.year, week=build_date.isocalendar()[1]
- )
- ChartRecord.build(
- user=user, year=build_date.year, month=build_date.month
- )
- ChartRecord.build(
- user=user,
- year=build_date.year,
- month=build_date.month,
- day=build_date.day,
- )
diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py
index 1523915..e9a762f 100644
--- a/vrobbler/apps/scrobbles/tasks.py
+++ b/vrobbler/apps/scrobbles/tasks.py
@@ -1,9 +1,9 @@
import logging
from celery import shared_task
+from charts.utils import build_all_historical_charts, build_yesterdays_charts
from django.apps import apps
from django.contrib.auth import get_user_model
-from scrobbles.stats import build_yesterdays_charts_for_user
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -54,4 +54,26 @@ def process_koreader_import(import_id):
@shared_task
def create_yesterdays_charts():
for user in User.objects.all():
- build_yesterdays_charts_for_user(user)
+ build_yesterdays_charts(user)
+
+
+@shared_task
+def build_missing_charts_for_all_users():
+ """Build missing charts for all users."""
+ for user in User.objects.all():
+ logger.info(f"Building all historical charts for {user}")
+ try:
+ build_all_historical_charts(user)
+ except Exception as e:
+ logger.error(f"Failed to build charts for {user}: {e}")
+
+
+@shared_task
+def build_charts_for_user(user_id):
+ """Build all historical charts for a specific user."""
+ user = User.objects.filter(id=user_id).first()
+ if not user:
+ logger.error(f"User with id {user_id} not found")
+ return
+ logger.info(f"Building all historical charts for {user}")
+ build_all_historical_charts(user)
diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py
index d0c9d94..782d57f 100644
--- a/vrobbler/apps/scrobbles/urls.py
+++ b/vrobbler/apps/scrobbles/urls.py
@@ -100,11 +100,6 @@ urlpatterns = [
views.ScrobbleRetroarchImportDetailView.as_view(),
name="retroarch-import-detail",
),
- path(
- "charts/",
- views.ChartRecordView.as_view(),
- name="charts-home",
- ),
path(
"long-plays/",
views.ScrobbleLongPlaysView.as_view(),
diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py
index c1a925e..ada99bb 100644
--- a/vrobbler/apps/scrobbles/views.py
+++ b/vrobbler/apps/scrobbles/views.py
@@ -58,7 +58,6 @@ from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.models import (
AudioScrobblerTSVImport,
- ChartRecord,
KoReaderImport,
LastFmImport,
RetroarchImport,
@@ -728,231 +727,6 @@ def export(request):
return response
-class ChartRecordView(TemplateView):
- template_name = "scrobbles/chart_index.html"
-
- @staticmethod
- def get_media_filter(media_type: str = "") -> Q:
- filters = {
- "Track": Q(track__isnull=False),
- "Artist": Q(artist__isnull=False),
- "Series": Q(series__isnull=False),
- "Video": Q(video__isnull=False),
- "": Q(),
- }
- return filters[media_type]
-
- def get_chart_records(self, media_type: str = "", **kwargs):
- media_filter = self.get_media_filter(media_type)
- charts = ChartRecord.objects.filter(
- media_filter, user=self.request.user, **kwargs
- ).order_by("rank")
-
- if charts.count() == 0:
- ChartRecord.build(user=self.request.user, model_str=media_type, **kwargs)
- charts = ChartRecord.objects.filter(
- media_filter, user=self.request.user, **kwargs
- ).order_by("rank")
- return charts
-
- def get_chart(
- self, period: str = "all_time", limit=15, media: str = ""
- ) -> QuerySet:
- now = timezone.now()
- params = {}
- params["media_type"] = media
- if period == "today":
- params["day"] = now.day
- params["month"] = now.month
- params["year"] = now.year
- if period == "week":
- params["week"] = now.ioscalendar()[1]
- params["year"] = now.year
- if period == "month":
- params["month"] = now.month
- params["year"] = now.year
- if period == "year":
- params["year"] = now.year
- return self.get_chart_records(**params)[:limit]
-
- def get_context_data(self, **kwargs):
- context_data = super().get_context_data(**kwargs)
- date = self.request.GET.get("date")
- media_type = self.request.GET.get("media", "Track")
- user = self.request.user
- params = {}
- context_data["chart_type"] = self.request.GET.get("chart_type", "maloja")
- context_data["artist_charts"] = {}
-
- if not date:
- limit = 20
- artist = {"user": user, "media_type": "Artist", "limit": limit}
- # This is weird. They don't display properly as QuerySets, so we cast to lists
- context_data["chart_keys"] = {
- "today": "Today",
- "last7": "Last 7 days",
- "last30": "Last 30 days",
- "year": "This year",
- "all": "All time",
- }
- context_data["current_artist_charts"] = batch_live_charts(
- user=user, media_type="Artist", limit=limit
- )
-
- track = {"user": user, "media_type": "Track", "limit": limit}
- context_data["current_track_charts"] = batch_live_charts(
- user=user, media_type="Track", limit=limit
- )
-
- now = timezone.now()
- tzinfo = now.tzinfo
- if user.is_authenticated:
- now = now_user_timezone(user.profile)
- tzinfo = now.tzinfo
- now = now.date()
- start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
- start_day_of_week = start_of_today - timedelta(days=now.isoweekday() % 7)
- start_day_of_month = now.replace(day=1)
-
- # TV Series Scrobbles - fetch all and filter in Python
- series_all = list(
- Scrobble.objects.with_related()
- .filter(
- user=user,
- video__video_type=Video.VideoType.TV_EPISODE,
- played_to_completion=True,
- timestamp__gte=start_day_of_month,
- )
- .order_by("-timestamp")
- )
- context_data["series_this_week"] = [
- s for s in series_all if s.timestamp >= start_day_of_week
- ]
- context_data["series_this_month"] = series_all
-
- # Movie Scrobbles - fetch all and filter in Python
- movies_all = list(
- Scrobble.objects.with_related()
- .filter(
- user=user,
- video__video_type=Video.VideoType.MOVIE,
- played_to_completion=True,
- timestamp__gte=start_day_of_month,
- )
- .order_by("-timestamp")
- )
- context_data["videos_this_week"] = [
- v for v in movies_all if v.timestamp >= start_day_of_week
- ]
- context_data["videos_this_month"] = movies_all
-
- # YouTube Scrobbles - fetch all and filter in Python
- youtube_all = list(
- Scrobble.objects.with_related()
- .filter(
- user=user,
- video__video_type=Video.VideoType.YOUTUBE,
- played_to_completion=True,
- timestamp__gte=start_day_of_month,
- )
- .order_by("-timestamp")
- )
- context_data["youtube_this_week"] = [
- y for y in youtube_all if y.timestamp >= start_day_of_week
- ]
- context_data["youtube_this_month"] = youtube_all
-
- context_data["tv_show_charts"] = {
- "today": list(live_tv_charts(user=user, chart_period="today")),
- "last7": list(live_tv_charts(user=user, chart_period="last7")),
- "last30": list(live_tv_charts(user=user, chart_period="last30")),
- "year": list(live_tv_charts(user=user, chart_period="year")),
- "all": list(live_tv_charts(user=user, chart_period="all")),
- }
-
- context_data["youtube_channel_charts"] = {
- "today": list(
- live_youtube_channel_charts(user=user, chart_period="today")
- ),
- "last7": list(
- live_youtube_channel_charts(user=user, chart_period="last7")
- ),
- "last30": list(
- live_youtube_channel_charts(user=user, chart_period="last30")
- ),
- "year": list(
- live_youtube_channel_charts(user=user, chart_period="year")
- ),
- "all": list(live_youtube_channel_charts(user=user, chart_period="all")),
- }
-
- return context_data
-
- # Date provided, lookup past charts, returning nothing if it's now or in the future.
- now = timezone.now()
- year = now.year
- params = {"year": year}
- name = f"Chart for {year}"
-
- date_params = date.split("-")
- year = int(date_params[0])
- in_progress = False
- if len(date_params) == 2:
- if "W" in date_params[1]:
- week = int(date_params[1].strip('W"'))
- params["week"] = week
- start = datetime.strptime(date + "-1", "%Y-W%W-%w").replace(
- tzinfo=pytz.utc
- )
- end = start + timedelta(days=6)
- in_progress = start <= now <= end
- as_str = start.strftime("Week of %B %d, %Y")
- name = f"Chart for {as_str}"
- else:
- month = int(date_params[1])
- params["month"] = month
- month_str = calendar.month_name[month]
- name = f"Chart for {month_str} {year}"
- in_progress = now.month == month and now.year == year
- if len(date_params) == 3:
- month = int(date_params[1])
- day = int(date_params[2])
- params["month"] = month
- params["day"] = day
- month_str = calendar.month_name[month]
- name = f"Chart for {month_str} {day}, {year}"
- in_progress = now.month == month and now.year == year and now.day == day
-
- media_filter = self.get_media_filter("Track")
- track_charts = ChartRecord.objects.filter(
- media_filter, user=self.request.user, **params
- ).order_by("rank")
- media_filter = self.get_media_filter("Artist")
- artist_charts = ChartRecord.objects.filter(
- media_filter, user=self.request.user, **params
- ).order_by("rank")
-
- if track_charts.count() == 0 and not in_progress:
- ChartRecord.build(user=self.request.user, model_str="Track", **params)
- media_filter = self.get_media_filter("Track")
- track_charts = ChartRecord.objects.filter(
- media_filter, user=self.request.user, **params
- ).order_by("rank")
- if artist_charts.count() == 0 and not in_progress:
- ChartRecord.build(user=self.request.user, model_str="Artist", **params)
- media_filter = self.get_media_filter("Artist")
- artist_charts = ChartRecord.objects.filter(
- media_filter, user=self.request.user, **params
- ).order_by("rank")
-
- context_data["media_type"] = media_type
- context_data["track_charts"] = track_charts
- context_data["artist_charts"] = artist_charts
- context_data["name"] = " ".join(["Top", media_type, "for", name])
- context_data["in_progress"] = in_progress
- return context_data
-
-
class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
model = Scrobble
template_name = "scrobbles/status.html"
diff --git a/vrobbler/settings.py b/vrobbler/settings.py
index 334bd93..3b18df6 100644
--- a/vrobbler/settings.py
+++ b/vrobbler/settings.py
@@ -2,7 +2,7 @@ import os
import sys
from pathlib import Path
-
+from celery.schedules import crontab
import dj_database_url
from dotenv import load_dotenv
@@ -116,6 +116,17 @@ CELERY_RESULT_BACKEND = "django-db"
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
CELERY_TASK_TRACK_STARTED = True
+CELERY_BEAT_SCHEDULE = {
+ "build-yesterdays-charts": {
+ "task": "scrobbles.tasks.create_yesterdays_charts",
+ "schedule": crontab(hour=0, minute=5),
+ },
+ "build-missing-charts-weekly": {
+ "task": "scrobbles.tasks.build_missing_charts_for_all_users",
+ "schedule": crontab(hour=1, minute=0, day_of_week=0),
+ },
+}
+
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
@@ -137,6 +148,7 @@ INSTALLED_APPS = [
"profiles",
"scrobbles",
"people",
+ "charts",
"videos",
"music",
"podcasts",
diff --git a/vrobbler/templates/base_detail.html b/vrobbler/templates/base_detail.html
index 228bae6..fb29711 100644
--- a/vrobbler/templates/base_detail.html
+++ b/vrobbler/templates/base_detail.html
@@ -7,15 +7,10 @@