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

+
+ + + + + + + + + + {% for chart in charts.artist %} + + + + + + {% endfor %} + +
RankArtistScrobbles
{{chart.rank}}{{chart.artist.name}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.track %} +
+

🎵 Top Tracks

+
+ + + + + + + + + + {% for chart in charts.track %} + + + + + + {% endfor %} + +
RankTrackScrobbles
{{chart.rank}}{{chart.track.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.tv_series %} +
+

📺 Top TV Shows

+
+ + + + + + + + + + {% for chart in charts.tv_series %} + + + + + + {% endfor %} + +
RankTV ShowEpisodes Watched
{{chart.rank}}{{chart.tv_series.name}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.album %} +
+

💿 Top Albums

+
+ + + + + + + + + + {% for chart in charts.album %} + + + + + + {% endfor %} + +
RankAlbumScrobbles
{{chart.rank}}{{chart.album.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.video %} +
+

🎬 Top Videos

+
+ + + + + + + + + + {% for chart in charts.video %} + + + + + + {% endfor %} + +
RankVideoPlays
{{chart.rank}}{{chart.video.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.board_game %} +
+

🎲 Top Board Games

+
+ + + + + + + + + + {% for chart in charts.board_game %} + + + + + + {% endfor %} + +
RankBoard GamePlays
{{chart.rank}}{{chart.board_game.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.book %} +
+

📚 Top Books

+
+ + + + + + + + + + {% for chart in charts.book %} + + + + + + {% endfor %} + +
RankBookPages Read
{{chart.rank}}{{chart.book.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.food %} +
+

🍽️ Top Foods

+
+ + + + + + + + + + {% for chart in charts.food %} + + + + + + {% endfor %} + +
RankFoodTimes Eaten
{{chart.rank}}{{chart.food.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.podcast %} +
+

🎙️ Top Podcasts

+
+ + + + + + + + + + {% for chart in charts.podcast %} + + + + + + {% endfor %} + +
RankPodcastEpisodes
{{chart.rank}}{{chart.podcast.title}}{{chart.count}}
+
+
+ {% endif %} + + {% if charts.trail %} +
+

🥾 Top Trails

+
+ + + + + + + + + + {% for chart in charts.trail %} + + + + + + {% endfor %} + +
RankTrailHikes
{{chart.rank}}{{chart.trail.name}}{{chart.count}}
+
+
+ {% 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 @@
{% if user.is_authenticated %}
-
- -
+ Admin
- Charts + Charts
- + Admin
- Charts + Charts
{% endif %}
diff --git a/vrobbler/templates/charts/chart_index.html b/vrobbler/templates/charts/chart_index.html new file mode 100644 index 0000000..a8097e5 --- /dev/null +++ b/vrobbler/templates/charts/chart_index.html @@ -0,0 +1,209 @@ +{% extends "base_list.html" %} + +{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block lists %} + +{% if chart_type == "maloja" %} +{% include "scrobbles/_top_charts.html" %} +{% else %} + +
+
+
+ {% if year %}{{year}}{% if month %} {{month|date:"F"}}{% endif %}{% endif %} + {% if week %}Week {{week}}{% endif %} + {% if day %}{{day}}{% endif %} +
+
+
+ +
+ {% if charts.artist %} +
+

🎤 Top Artists

+
    + {% for chart in charts.artist|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.artist.name}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.album %} +
+

💿 Top Albums

+
    + {% for chart in charts.album|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.album.name}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.track %} +
+

🎵 Top Tracks

+
    + {% for chart in charts.track|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.track.title}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.tv_series %} +
+

📺 Top TV Series

+
    + {% for chart in charts.tv_series|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.tv_series.name}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.video %} +
+

🎬 Top Videos

+
    + {% for chart in charts.video|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.video.title}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.podcast %} +
+

🎙️ Top Podcasts

+
    + {% for chart in charts.podcast|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.podcast.name}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.board_game %} +
+

🎲 Top Board Games

+
    + {% for chart in charts.board_game|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.board_game.title}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.book %} +
+

📚 Top Books

+
    + {% for chart in charts.book|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.book.title}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.food %} +
+

🍽️ Top Foods

+
    + {% for chart in charts.food|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.food.title}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.trail %} +
+

🥾 Top Trails

+
    + {% for chart in charts.trail|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.trail.name}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if charts.geo_location %} +
+

📍 Top Locations

+
    + {% for chart in charts.geo_location|slice:":20" %} +
  • + #{{chart.rank}} + {{chart.geo_location.name}} + {{chart.count}} +
  • + {% endfor %} +
+
+ {% endif %} +
+ +{% if not charts %} +
+ No chart data for this period. Charts are built asynchronously - check back later or trigger a chart rebuild. +
+{% endif %} + +{% endif %} + +{% endblock %} diff --git a/vrobbler/templates/scrobbles/_top_charts.html b/vrobbler/templates/scrobbles/_top_charts.html index 4340bd1..a15e91b 100644 --- a/vrobbler/templates/scrobbles/_top_charts.html +++ b/vrobbler/templates/scrobbles/_top_charts.html @@ -1,644 +1,225 @@ -{% load static %} - +{% load static chart_tags %} +{% if maloja_charts %}
-

Top Artist

+

🎤 Top Artists

- {% for key, artists in current_artist_charts.items %} -
+ {% for key, name in chart_keys.items %} + {% with maloja_charts.artist|get_item:key as artists %} +
+ {% if artists.0 %}
-
#1 {{artists.0.name}}
- {% if artists.0 %} - {% if artists.0.thumbnail %} - +
#1 {{artists.0.artist.name}}
+ {% if artists.0.artist.thumbnail %} + {{artists.0.artist.name}} {% else %} - - {% endif %} + {{artists.0.artist.name}} {% endif %}
-
-
#2 {{artists.1.name}}
- {% if artists.1 %} - {% if artists.1.thumbnail %} - + {% for i in "2345" %} + {% with artists|get_item:forloop.counter as artist %} + {% if artist %} +
+
#{{forloop.counter|add:1}} {{artist.artist.name}}
+ {% if artist.artist.thumbnail %} + {% else %} - - {% endif %} - {% endif %} -
-
-
#3 {{artists.2.name}}
- {% if artists.2 %} - {% if artists.2.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#4 {{artists.3.name}}
- {% if artists.3 %} - {% if artists.3.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#5 {{artists.4.name}}
- {% if artists.4 %} - {% if artists.4.thumbnail %} - - {% else %} - - {% endif %} + {% endif %}
+ {% endif %} + {% endwith %} + {% endfor %}
-
-
#6 {{artists.5.name}}
- {% if artists.5 %} - {% if artists.5.thumbnail %} - + {% for i in "67891011121314" %} + {% with artists|get_item:forloop.counter|add:5 as artist %} + {% if artist %} +
+
#{{forloop.counter|add:6}} {{artist.artist.name}}
+ {% if artist.artist.thumbnail %} + {% else %} - - {% endif %} - {% endif %} -
-
-
#7 {{artists.6.name}}
- {% if artists.6 %} - {% if artists.6.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#8 {{artists.7.name}}
- {% if artists.7 %} - {% if artists.7.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#9 {{artists.8.name}}
- {% if artists.8 %} - {% if artists.8.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#10 {{artists.9.name}}
- {% if artists.9 %} - {% if artists.9.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#11 {{artists.10.name}}
- {% if artists.10 %} - {% if artists.10.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#12 {{artists.11.name}}
- {% if artists.11 %} - {% if artists.11.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#13 {{artists.12.name}}
- {% if artists.12 %} - {% if artists.12.thumbnail %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#14 {{artists.13.name}}
- {% if artists.13 %} - {% if artists.13.thumbnail %} - - {% else %} - - {% endif %} + {% endif %}
+ {% endif %} + {% endwith %} + {% endfor %}
+ {% else %} +

No data for this period

+ {% endif %}
+ {% endwith %} {% endfor %}
-

Top Tracks

- +

🎵 Top Tracks

+ -
- {% for chart_name, tracks in current_track_charts.items %} -
-
-
-
-
#1 {{tracks.0.title}}
- {% if tracks.0 %} - {% if tracks.0.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
-
-
-
#2 {{tracks.1.title}}
- {% if tracks.1 %} - {% if tracks.1.album.cover_image %} - +
+ {% for key, name in chart_keys.items %} + {% with maloja_charts.track|get_item:key as tracks %} +
+ {% if tracks.0 %} +
+
+
+
#1 {{tracks.0.track.title}}
+ {% if tracks.0.track.album.cover_image %} + {% else %} - - {% endif %} - {% endif %} -
-
-
#3 {{tracks.2.title}}
- {% if tracks.2 %} - {% if tracks.2.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#4 {{tracks.3.title}}
- {% if tracks.3 %} - {% if tracks.3.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#5 {{tracks.4.title}}
- {% if tracks.4 %} - {% if tracks.4.album.cover_image %} - - {% else %} - - {% endif %} + {% endif %}
-
-
-
-
-
#6 {{tracks.5.title}}
- {% if tracks.5 %} - {% if tracks.5.album.cover_image %} - - {% else %} - - {% endif %} +
+
+ {% for i in "2345" %} + {% with tracks|get_item:forloop.counter as track %} + {% if track %} +
+
#{{forloop.counter|add:1}} {{track.track.title}}
+ {% if track.track.album.cover_image %} + + {% else %} + + {% endif %} +
{% endif %} + {% endwith %} + {% endfor %}
-
-
#7 {{tracks.6.title}}
- {% if tracks.6 %} - {% if tracks.6.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#8 {{tracks.7.title}}
- {% if tracks.7 %} - {% if tracks.7.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#9 {{tracks.8.title}}
- {% if tracks.8 %} - {% if tracks.8.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#10 {{tracks.9.title}}
- {% if tracks.9 %} - {% if tracks.9.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#11 {{tracks.10.title}}
- {% if tracks.10 %} - {% if tracks.10.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#12 {{tracks.11.title}}
- {% if tracks.11 %} - {% if tracks.11.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#13 {{tracks.12.title}}
- {% if tracks.12 %} - {% if tracks.12.album.cover_image %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
#14 {{tracks.13.title}}
- {% if tracks.13 %} - {% if tracks.13.album.cover_image %} - - {% else %} - - {% endif %} +
+
+
+ {% for i in "67891011121314" %} + {% with tracks|get_item:forloop.counter|add:5 as track %} + {% if track %} +
+
#{{forloop.counter|add:6}} {{track.track.title}}
+ {% if track.track.album.cover_image %} + + {% else %} + + {% endif %} +
{% endif %} + {% endwith %} + {% endfor %}
+ {% else %} +

No data for this period

+ {% endif %}
+ {% endwith %} + {% endfor %}
- {% endfor %}
-

Top TV Shows

+

📺 Top TV Shows

- {% for key, shows in tv_show_charts.items %} -
-
- {% if shows.0 %} -
-
-
#1 {{shows.0.name}} ({{shows.0.num_scrobbles}} episodes)
- {% if shows.0.cover_image %} - - {% else %} - - {% endif %} -
-
- {% endif %} - {% if shows.1 %} -
-
-
-
#2 {{shows.1.name}} ({{shows.1.num_scrobbles}})
- {% if shows.1.cover_image %} - - {% else %} - - {% endif %} -
-
-
#3 {{shows.2.name}} ({{shows.2.num_scrobbles}})
- {% if shows.2.cover_image %} - - {% else %} - - {% endif %} -
-
-
#4 {{shows.3.name}} ({{shows.3.num_scrobbles}})
- {% if shows.3.cover_image %} - - {% else %} - - {% endif %} -
-
-
#5 {{shows.4.name}} ({{shows.4.num_scrobbles}})
- {% if shows.4.cover_image %} - - {% else %} - - {% endif %} -
-
-
- {% endif %} - {% if shows.5 %} -
-
-
-
#6 {{shows.5.name}}
- {% if shows.5.cover_image %} - - {% else %} - - {% endif %} -
-
-
#7 {{shows.6.name}}
- {% if shows.6.cover_image %} - - {% else %} - - {% endif %} -
-
-
#8 {{shows.7.name}}
- {% if shows.7.cover_image %} - - {% else %} - - {% endif %} -
-
-
#9 {{shows.8.name}}
- {% if shows.8.cover_image %} - - {% else %} - - {% endif %} -
-
-
#10 {{shows.9.name}}
- {% if shows.9.cover_image %} - - {% else %} - - {% endif %} -
-
-
#11 {{shows.10.name}}
- {% if shows.10.cover_image %} - - {% else %} - - {% endif %} -
-
-
#12 {{shows.11.name}}
- {% if shows.11.cover_image %} - - {% else %} - - {% endif %} -
-
-
#13 {{shows.12.name}}
- {% if shows.12.cover_image %} - - {% else %} - - {% endif %} -
-
-
#14 {{shows.13.name}}
- {% if shows.13.cover_image %} - - {% else %} - - {% endif %} -
-
-
- {% endif %} -
-
- {% endfor %} -
-
- -
-

Top YouTube Channels

- - -
- {% for key, channels in youtube_channel_charts.items %} -
+ {% with maloja_charts.tv_series|get_item:key as shows %} +
+ {% if shows.0 %}
- {% if channels.0 %}
-
#1 {{channels.0.name}} ({{channels.0.num_scrobbles}} videos)
- {% if channels.0.cover_image %} - +
#1 {{shows.0.tv_series.name}}
+ {% if shows.0.tv_series.cover_image %} + {{shows.0.tv_series.name}} {% else %} - + {{shows.0.tv_series.name}} {% endif %}
- {% endif %} - {% if channels.1 %}
+ {% for i in "2345" %} + {% with shows|get_item:forloop.counter as show %} + {% if show %}
-
#2 {{channels.1.name}} ({{channels.1.num_scrobbles}})
- {% if channels.1.cover_image %} - +
#{{forloop.counter|add:1}} {{show.tv_series.name}}
+ {% if show.tv_series.cover_image %} + {% else %} - - {% endif %} -
-
-
#3 {{channels.2.name}} ({{channels.2.num_scrobbles}})
- {% if channels.2.cover_image %} - - {% else %} - - {% endif %} -
-
-
#4 {{channels.3.name}} ({{channels.3.num_scrobbles}})
- {% if channels.3.cover_image %} - - {% else %} - - {% endif %} -
-
-
#5 {{channels.4.name}} ({{channels.4.num_scrobbles}})
- {% if channels.4.cover_image %} - - {% else %} - + {% endif %}
+ {% endif %} + {% endwith %} + {% endfor %}
- {% endif %} - {% if channels.5 %}
+ {% for i in "67891011121314" %} + {% with shows|get_item:forloop.counter|add:5 as show %} + {% if show %}
-
#6 {{channels.5.name}}
- {% if channels.5.cover_image %} - +
#{{forloop.counter|add:6}} {{show.tv_series.name}}
+ {% if show.tv_series.cover_image %} + {% else %} - - {% endif %} -
-
-
#7 {{channels.6.name}}
- {% if channels.6.cover_image %} - - {% else %} - - {% endif %} -
-
-
#8 {{channels.7.name}}
- {% if channels.7.cover_image %} - - {% else %} - - {% endif %} -
-
-
#9 {{channels.8.name}}
- {% if channels.8.cover_image %} - - {% else %} - - {% endif %} -
-
-
#10 {{channels.9.name}}
- {% if channels.9.cover_image %} - - {% else %} - - {% endif %} -
-
-
#11 {{channels.10.name}}
- {% if channels.10.cover_image %} - - {% else %} - - {% endif %} -
-
-
#12 {{channels.11.name}}
- {% if channels.11.cover_image %} - - {% else %} - - {% endif %} -
-
-
#13 {{channels.12.name}}
- {% if channels.12.cover_image %} - - {% else %} - - {% endif %} -
-
-
#14 {{channels.13.name}}
- {% if channels.13.cover_image %} - - {% else %} - + {% endif %}
+ {% endif %} + {% endwith %} + {% endfor %}
- {% endif %}
+ {% else %} +

No data for this period

+ {% endif %}
+ {% endwith %} {% endfor %}
+{% endif %} diff --git a/vrobbler/templates/scrobbles/scrobble_list.html b/vrobbler/templates/scrobbles/scrobble_list.html index 4065894..186792b 100644 --- a/vrobbler/templates/scrobbles/scrobble_list.html +++ b/vrobbler/templates/scrobbles/scrobble_list.html @@ -57,12 +57,7 @@
{% if user.is_authenticated %}
-
- -
+ Admin
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 7e50147..0b6225d 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -98,6 +98,7 @@ from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet from vrobbler.apps.people import urls as people_urls +from vrobbler.apps.charts import urls as charts_urls # from vrobbler.apps.modern_ui import urls as modern_ui_urls @@ -170,6 +171,7 @@ urlpatterns = [ path("", include(scrobble_urls, namespace="scrobbles")), path("", include(profiles_urls, namespace="profiles")), path("", include(people_urls, namespace="people")), + path("", include(charts_urls, namespace="charts")), path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"), ] if settings.DEBUG: