[charts] Big revamp of the charts app
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 24s

This commit is contained in:
2026-03-21 18:13:34 -04:00
parent 565adfe58e
commit d5d27256f8
31 changed files with 2006 additions and 1100 deletions

View File

View File

@ -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

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChartsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "charts"

View File

@ -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!"))

View File

@ -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")
)

View File

@ -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",
),
],
},
),
]

View File

@ -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")

View File

@ -0,0 +1,315 @@
{% extends "base_list.html" %}
{% load chart_tags %}
{% block title %}Charts{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom:100px; }
h2 { padding-top:20px; }
.image-wrapper {
contain: content;
}
.image-wrapper :hover {
background:rgba(0,0,0,0.3);
}
.caption {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 90%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 75%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-small {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 60%;
color:white;
background:rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
{% if chart_type == "maloja" %}
{% include "scrobbles/_top_charts.html" %}
{% else %}
<div class="row">
{% if charts.artist %}
<div class="col-md">
<h2>🎤 Top Artists</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in charts.artist %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.track %}
<div class="col-md">
<h2>🎵 Top Tracks</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in charts.track %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.track.get_absolute_url}}">{{chart.track.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md">
<h2>📺 Top TV Shows</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">TV Show</th>
<th scope="col">Episodes Watched</th>
</tr>
</thead>
<tbody>
{% for chart in charts.tv_series %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md">
<h2>💿 Top Albums</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Album</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in charts.album %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.album.get_absolute_url}}">{{chart.album.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.video %}
<div class="col-md">
<h2>🎬 Top Videos</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Video</th>
<th scope="col">Plays</th>
</tr>
</thead>
<tbody>
{% for chart in charts.video %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.video.get_absolute_url}}">{{chart.video.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.board_game %}
<div class="col-md">
<h2>🎲 Top Board Games</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Board Game</th>
<th scope="col">Plays</th>
</tr>
</thead>
<tbody>
{% for chart in charts.board_game %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.board_game.get_absolute_url}}">{{chart.board_game.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.book %}
<div class="col-md">
<h2>📚 Top Books</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Book</th>
<th scope="col">Pages Read</th>
</tr>
</thead>
<tbody>
{% for chart in charts.book %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.book.get_absolute_url}}">{{chart.book.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.food %}
<div class="col-md">
<h2>🍽️ Top Foods</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Food</th>
<th scope="col">Times Eaten</th>
</tr>
</thead>
<tbody>
{% for chart in charts.food %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.food.get_absolute_url}}">{{chart.food.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.podcast %}
<div class="col-md">
<h2>🎙️ Top Podcasts</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Podcast</th>
<th scope="col">Episodes</th>
</tr>
</thead>
<tbody>
{% for chart in charts.podcast %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.podcast.get_absolute_url}}">{{chart.podcast.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.trail %}
<div class="col-md">
<h2>🥾 Top Trails</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Trail</th>
<th scope="col">Hikes</th>
</tr>
</thead>
<tbody>
{% for chart in charts.trail %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -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

View File

@ -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"),
]

View File

@ -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)

View File

@ -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

View File

@ -1,7 +1,7 @@
from django.db.models import Count from django.db.models import Count
from django.views import generic from django.views import generic
from music.models import Album, Artist, Track 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.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
@ -35,9 +35,7 @@ class ArtistListView(generic.ListView):
) )
def get_context_data(self, *, object_list=None, **kwargs): def get_context_data(self, *, object_list=None, **kwargs):
context_data = super().get_context_data( context_data = super().get_context_data(object_list=object_list, **kwargs)
object_list=object_list, **kwargs
)
context_data["view"] = self.request.GET.get("view") context_data["view"] = self.request.GET.get("view")
return context_data return context_data

View File

@ -2,7 +2,6 @@ from django.contrib import admin
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
ChartRecord,
KoReaderImport, KoReaderImport,
LastFmImport, LastFmImport,
RetroarchImport, RetroarchImport,
@ -71,6 +70,7 @@ class KoReaderImportAdmin(ImportBaseAdmin):
@admin.register(RetroarchImport) @admin.register(RetroarchImport)
class RetroarchImportAdmin(ImportBaseAdmin): class RetroarchImportAdmin(ImportBaseAdmin):
... ...
class RetroarchImportAdmin(ImportBaseAdmin): ...
@admin.register(Genre) @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) @admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin): class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp" date_hierarchy = "timestamp"

View File

@ -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",
),
]

View File

@ -48,7 +48,7 @@ from scrobbles.constants import (
) )
from scrobbles.importers.lastfm import LastFM from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import ScrobbleNtfyNotification 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 scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent from sports.models import SportEvent
from tasks.models import Task from tasks.models import Task
@ -344,140 +344,6 @@ class RetroarchImport(BaseFileImportMixin):
self.mark_finished() 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): class ScrobbleQuerySet(models.QuerySet):
def with_related(self): def with_related(self):
return self.select_related("user").prefetch_related( return self.select_related("user").prefetch_related(

View File

@ -44,13 +44,9 @@ def get_scrobble_count_qs(
if model_str == "Video": if model_str == "Video":
data_model = apps.get_model(app_label="videos", model_name="Video") data_model = apps.get_model(app_label="videos", model_name="Video")
if model_str == "SportEvent": if model_str == "SportEvent":
data_model = apps.get_model( data_model = apps.get_model(app_label="sports", model_name="SportEvent")
app_label="sports", model_name="SportEvent"
)
if model_str == "GeoLocation": if model_str == "GeoLocation":
data_model = apps.get_model( data_model = apps.get_model(app_label="locations", model_name="GeoLocation")
app_label="locations", model_name="GeoLocation"
)
if model_str == "Artist": if model_str == "Artist":
base_qs = data_model.objects.filter( base_qs = data_model.objects.filter(
@ -106,135 +102,3 @@ def get_scrobble_count_qs(
) )
return 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,
)

View File

@ -1,9 +1,9 @@
import logging import logging
from celery import shared_task from celery import shared_task
from charts.utils import build_all_historical_charts, build_yesterdays_charts
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from scrobbles.stats import build_yesterdays_charts_for_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -54,4 +54,26 @@ def process_koreader_import(import_id):
@shared_task @shared_task
def create_yesterdays_charts(): def create_yesterdays_charts():
for user in User.objects.all(): 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)

View File

@ -100,11 +100,6 @@ urlpatterns = [
views.ScrobbleRetroarchImportDetailView.as_view(), views.ScrobbleRetroarchImportDetailView.as_view(),
name="retroarch-import-detail", name="retroarch-import-detail",
), ),
path(
"charts/",
views.ChartRecordView.as_view(),
name="charts-home",
),
path( path(
"long-plays/", "long-plays/",
views.ScrobbleLongPlaysView.as_view(), views.ScrobbleLongPlaysView.as_view(),

View File

@ -58,7 +58,6 @@ from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
ChartRecord,
KoReaderImport, KoReaderImport,
LastFmImport, LastFmImport,
RetroarchImport, RetroarchImport,
@ -728,231 +727,6 @@ def export(request):
return response 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): class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
model = Scrobble model = Scrobble
template_name = "scrobbles/status.html" template_name = "scrobbles/status.html"

View File

@ -2,7 +2,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from celery.schedules import crontab
import dj_database_url import dj_database_url
from dotenv import load_dotenv 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_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
CELERY_TASK_TRACK_STARTED = True 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 = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@ -137,6 +148,7 @@ INSTALLED_APPS = [
"profiles", "profiles",
"scrobbles", "scrobbles",
"people", "people",
"charts",
"videos", "videos",
"music", "music",
"podcasts", "podcasts",

View File

@ -7,15 +7,10 @@
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="btn-group me-2"> <div class="btn-group me-2">
<form action="/admin/" method="get"> <a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
<button type="submit" class="btn btn-sm btn-outline-secondary">
<span data-feather="key"></span>
Admin
</button>
</form>
</div> </div>
<div class="btn-group me-2"> <div class="btn-group me-2">
<a href="{% url 'scrobbles:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a> <a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
</div> </div>
<div class="btn-group me-2"> <div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal"

View File

@ -9,15 +9,10 @@
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="btn-group me-2"> <div class="btn-group me-2">
<form action="/admin/" method="get"> <a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
<button type="submit" class="btn btn-sm btn-outline-secondary">
<span data-feather="key"></span>
Admin
</button>
</form>
</div> </div>
<div class="btn-group me-2"> <div class="btn-group me-2">
<a href="{% url 'scrobbles:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a> <a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
</div> </div>
{% endif %} {% endif %}
<div class="btn-group me-2"> <div class="btn-group me-2">

View File

@ -0,0 +1,209 @@
{% extends "base_list.html" %}
{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom: 100px; }
h2 { padding-top: 20px; }
.chart-section {
margin-bottom: 2rem;
}
.nav-tabs {
cursor: pointer;
}
</style>
{% endblock %}
{% block lists %}
{% if chart_type == "maloja" %}
{% include "scrobbles/_top_charts.html" %}
{% else %}
<div class="row">
<div class="col-12">
<div class="btn-group mb-3" role="group">
{% if year %}<a href="?date={{year}}{% if month %}-{{month}}{% endif %}" class="btn btn-outline-secondary{% if not week and not day %} active{% endif %}">{{year}}{% if month %} {{month|date:"F"}}{% endif %}</a>{% endif %}
{% if week %}<span class="btn btn-outline-secondary active">Week {{week}}</span>{% endif %}
{% if day %}<span class="btn btn-outline-secondary active">{{day}}</span>{% endif %}
</div>
</div>
</div>
<div class="row">
{% if charts.artist %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎤 Top Artists</h3>
<ul class="list-group">
{% for chart in charts.artist|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>💿 Top Albums</h3>
<ul class="list-group">
{% for chart in charts.album|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.track %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎵 Top Tracks</h3>
<ul class="list-group">
{% for chart in charts.track|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.track.get_absolute_url}}">{{chart.track.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📺 Top TV Series</h3>
<ul class="list-group">
{% for chart in charts.tv_series|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.video %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎬 Top Videos</h3>
<ul class="list-group">
{% for chart in charts.video|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.video.get_absolute_url}}">{{chart.video.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.podcast %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎙️ Top Podcasts</h3>
<ul class="list-group">
{% for chart in charts.podcast|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.podcast.get_absolute_url}}">{{chart.podcast.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.board_game %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎲 Top Board Games</h3>
<ul class="list-group">
{% for chart in charts.board_game|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.board_game.get_absolute_url}}">{{chart.board_game.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.book %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📚 Top Books</h3>
<ul class="list-group">
{% for chart in charts.book|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.book.get_absolute_url}}">{{chart.book.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.food %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🍽️ Top Foods</h3>
<ul class="list-group">
{% for chart in charts.food|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.food.get_absolute_url}}">{{chart.food.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.trail %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🥾 Top Trails</h3>
<ul class="list-group">
{% for chart in charts.trail|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if charts.geo_location %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📍 Top Locations</h3>
<ul class="list-group">
{% for chart in charts.geo_location|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<span>{{chart.geo_location.name}}</span>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if not charts %}
<div class="alert alert-info">
No chart data for this period. Charts are built asynchronously - check back later or trigger a chart rebuild.
</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -1,644 +1,225 @@
{% load static %} {% load static chart_tags %}
{% if maloja_charts %}
<div class="row"> <div class="row">
<h2>Top Artist</h2> <h2>🎤 Top Artists</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist"> <ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for key, name in chart_keys.items %} {% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}" <button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
id="artist-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{key}}" id="artist-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{key}}"
type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button> type="button" role="tab">{{name}}</button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="tab-content" id="artistTabContent" class="maloja-chart"> <div class="tab-content" id="artistTabContent" class="maloja-chart">
{% for key, artists in current_artist_charts.items %} {% for key, name in chart_keys.items %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="artist-{{key}}" role="tabpanel" aria-labelledby="artist-{{key}}-tab"> {% with maloja_charts.artist|get_item:key as artists %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="artist-{{key}}" role="tabpanel">
{% if artists.0 %}
<div style="display:block"> <div style="display:block">
<div style="float:left;"> <div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0"> <div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{artists.0.name}}</div> <div class="caption">#1 {{artists.0.artist.name}}</div>
{% if artists.0 %} {% if artists.0.artist.thumbnail %}
{% if artists.0.thumbnail %} <a href="{{artists.0.artist.get_absolute_url}}"><img alt="{{artists.0.artist.name}}" src="{{artists.0.artist.thumbnail_medium.url}}" width="300px"></a>
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{{artists.0.thumbnail_medium.url}}" width="300px"></a>
{% else %} {% else %}
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px"></a> <a href="{{artists.0.artist.get_absolute_url}}"><img alt="{{artists.0.artist.name}}" src="{% static "images/not-found.jpg" %}" width="300px"></a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" class="image-wrapper" style="width:50%"> {% for i in "2345" %}
<div class="caption-medium">#2 {{artists.1.name}}</div> {% with artists|get_item:forloop.counter as artist %}
{% if artists.1 %} {% if artist %}
{% if artists.1.thumbnail %} <div class="image-wrapper" style="width:50%">
<a href="{{artists.1.get_absolute_url}}"><img lt="{{artists.1.name}}" src="{{artists.1.thumbnail_medium.url}}" width="150px"></a> <div class="caption-medium">#{{forloop.counter|add:1}} {{artist.artist.name}}</div>
{% if artist.artist.thumbnail %}
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="150px"></a>
{% else %} {% else %}
<a href="{{artists.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a> <a href="{{artist.artist.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{artists.2.name}}</div>
{% if artists.2 %}
{% if artists.2.thumbnail %}
<a href="{{artists.2.get_absolute_url}}"><img src="{{artists.2.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{artists.3.name}}</div>
{% if artists.3 %}
{% if artists.3.thumbnail %}
<a href="{{artists.3.get_absolute_url}}"><img src="{{artists.3.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{artists.4.name}}</div>
{% if artists.4 %}
{% if artists.4.thumbnail %}
<a href="{{artists.4.get_absolute_url}}"><img src="{{artists.4.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endwith %}
{% endfor %}
</div> </div>
</div> </div>
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" class="image-wrapper" class="image-wrapper" style="width:33;"> {% for i in "67891011121314" %}
<div class="caption-small">#6 {{artists.5.name}}</div> {% with artists|get_item:forloop.counter|add:5 as artist %}
{% if artists.5 %} {% if artist %}
{% if artists.5.thumbnail %} <div class="image-wrapper" style="width:33%">
<a href="{{artists.5.get_absolute_url}}"><img src="{{artists.5.thumbnail_medium.url}}" width="100px"></a> <div class="caption-small">#{{forloop.counter|add:6}} {{artist.artist.name}}</div>
{% if artist.artist.thumbnail %}
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a>
{% else %} {% else %}
<a href="{{artists.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a> <a href="{{artist.artist.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#7 {{artists.6.name}}</div>
{% if artists.6 %}
{% if artists.6.thumbnail %}
<a href="{{artists.6.get_absolute_url}}"><img src="{{artists.6.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#8 {{artists.7.name}}</div>
{% if artists.7 %}
{% if artists.7.thumbnail %}
<a href="{{artists.7.get_absolute_url}}"><img src="{{artists.7.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#9 {{artists.8.name}}</div>
{% if artists.8 %}
{% if artists.8.thumbnail %}
<a href="{{artists.8.get_absolute_url}}"><img src="{{artists.8.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#10 {{artists.9.name}}</div>
{% if artists.9 %}
{% if artists.9.thumbnail %}
<a href="{{artists.9.get_absolute_url}}"><img src="{{artists.9.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#11 {{artists.10.name}}</div>
{% if artists.10 %}
{% if artists.10.thumbnail %}
<a href="{{artists.10.get_absolute_url}}"><img src="{{artists.10.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#12 {{artists.11.name}}</div>
{% if artists.11 %}
{% if artists.11.thumbnail %}
<a href="{{artists.11.get_absolute_url}}"><img src="{{artists.11.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#13 {{artists.12.name}}</div>
{% if artists.12 %}
{% if artists.12.thumbnail %}
<a href="{{artists.12.get_absolute_url}}"><img src="{{artists.12.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#14 {{artists.13.name}}</div>
{% if artists.13 %}
{% if artists.13.thumbnail %}
<a href="{{artists.13.get_absolute_url}}"><img src="{{artists.13.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endwith %}
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% else %}
<p class="text-muted">No data for this period</p>
{% endif %}
</div> </div>
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<h2>Top Tracks</h2> <h2>🎵 Top Tracks</h2>
<ul class="nav nav-tabs" id="trackTab" role="tablist"> <ul class="nav nav-tabs" id="trackTab" role="tablist">
{% for key, name in chart_keys.items %} {% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}" id="track-{{key}}-tab" data-bs-toggle="tab" <button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
data-bs-target="#track-{{key}}" type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button> id="track-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{key}}"
</li> type="button" role="tab">{{name}}</button>
{% endfor %} </li>
</ul> {% endfor %}
</ul>
<div class="tab-content" id="trackTabContent" class="maloja-chart"> <div class="tab-content" id="trackTabContent" class="maloja-chart">
{% for chart_name, tracks in current_track_charts.items %} {% for key, name in chart_keys.items %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="track-{{chart_name}}" role="tabpanel" aria-labelledby="track-{{chart_name}}-tab"> {% with maloja_charts.track|get_item:key as tracks %}
<div style="display:block"> <div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="track-{{key}}" role="tabpanel">
<div style="float:left;"> {% if tracks.0 %}
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0"> <div style="display:block">
<div class="caption">#1 {{tracks.0.title}}</div> <div style="float:left;">
{% if tracks.0 %} <div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
{% if tracks.0.album.cover_image %} <div class="caption">#1 {{tracks.0.track.title}}</div>
<a href="{{tracks.0.get_absolute_url}}"><img src="{{tracks.0.album.cover_image_medium.url}}" width="300px"></a> {% if tracks.0.track.album.cover_image %}
{% else %} <a href="{{tracks.0.track.get_absolute_url}}"><img src="{{tracks.0.track.album.cover_image_medium.url}}" width="300px"></a>
<a href="{{tracks.0.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
{% endif %}
{% endif %}
</div>
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#2 {{tracks.1.title}}</div>
{% if tracks.1 %}
{% if tracks.1.album.cover_image %}
<a href="{{tracks.1.get_absolute_url}}"><img src="{{tracks.1.album.cover_image_medium.url}}" width="150px"></a>
{% else %} {% else %}
<a href="{{tracks.1.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a> <a href="{{tracks.0.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{tracks.2.title}}</div>
{% if tracks.2 %}
{% if tracks.2.album.cover_image %}
<a href="{{tracks.2.get_absolute_url}}"><img src="{{tracks.2.album.cover_image_medium.url}}" width="150px"></a>
{% else %}
<a href="{{tracks.2.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{tracks.3.title}}</div>
{% if tracks.3 %}
{% if tracks.3.album.cover_image %}
<a href="{{tracks.3.get_absolute_url}}"><img src="{{tracks.3.album.cover_image_medium.url}}" width="150px"></a>
{% else %}
<a href="{{tracks.3.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{tracks.4.title}}</div>
{% if tracks.4 %}
{% if tracks.4.album.cover_image %}
<a href="{{tracks.4.get_absolute_url}}"><img src="{{tracks.4.album.cover_image_medium.url}}" width="150px"></a>
{% else %}
<a href="{{tracks.4.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> <div style="float:left; width:300px;">
<div style="float:left; width:300px;"> <div style="display:flex; flex-wrap: wrap;">
<div style="display:flex; flex-wrap: wrap;"> {% for i in "2345" %}
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% with tracks|get_item:forloop.counter as track %}
<div class="caption-small">#6 {{tracks.5.title}}</div> {% if track %}
{% if tracks.5 %} <div class="image-wrapper" style="width:50%">
{% if tracks.5.album.cover_image %} <div class="caption-medium">#{{forloop.counter|add:1}} {{track.track.title}}</div>
<a href="{{tracks.5.get_absolute_url}}"><img src="{{tracks.5.album.cover_image_medium.url}}" width="100px"></a> {% if track.track.album.cover_image %}
{% else %} <a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="150px"></a>
<a href="{{tracks.5.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a> {% else %}
{% endif %} <a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
{% endif %}
</div>
{% endif %} {% endif %}
{% endwith %}
{% endfor %}
</div> </div>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> </div>
<div class="caption-small">#7 {{tracks.6.title}}</div> <div style="float:left; width:300px;">
{% if tracks.6 %} <div style="display:flex; flex-wrap: wrap;">
{% if tracks.6.album.cover_image %} {% for i in "67891011121314" %}
<a href="{{tracks.6.get_absolute_url}}"><img src="{{tracks.6.album.cover_image_medium.url}}" width="100px"></a> {% with tracks|get_item:forloop.counter|add:5 as track %}
{% else %} {% if track %}
<a href="{{tracks.6.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a> <div class="image-wrapper" style="width:33%">
{% endif %} <div class="caption-small">#{{forloop.counter|add:6}} {{track.track.title}}</div>
{% endif %} {% if track.track.album.cover_image %}
</div> <a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="100px"></a>
<div class="image-wrapper" class="image-wrapper" style="width:33;"> {% else %}
<div class="caption-small">#8 {{tracks.7.title}}</div> <a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% if tracks.7 %} {% endif %}
{% if tracks.7.album.cover_image %} </div>
<a href="{{tracks.7.get_absolute_url}}"><img src="{{tracks.7.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.7.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#9 {{tracks.8.title}}</div>
{% if tracks.8 %}
{% if tracks.8.album.cover_image %}
<a href="{{tracks.8.get_absolute_url}}"><img src="{{tracks.8.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.8.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#10 {{tracks.9.title}}</div>
{% if tracks.9 %}
{% if tracks.9.album.cover_image %}
<a href="{{tracks.9.get_absolute_url}}"><img src="{{tracks.9.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.9.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#11 {{tracks.10.title}}</div>
{% if tracks.10 %}
{% if tracks.10.album.cover_image %}
<a href="{{tracks.10.get_absolute_url}}"><img src="{{tracks.10.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.10.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#12 {{tracks.11.title}}</div>
{% if tracks.11 %}
{% if tracks.11.album.cover_image %}
<a href="{{tracks.11.get_absolute_url}}"><img src="{{tracks.11.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.11.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#13 {{tracks.12.title}}</div>
{% if tracks.12 %}
{% if tracks.12.album.cover_image %}
<a href="{{tracks.12.get_absolute_url}}"><img src="{{tracks.12.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.12.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#14 {{tracks.13.title}}</div>
{% if tracks.13 %}
{% if tracks.13.album.cover_image %}
<a href="{{tracks.13.get_absolute_url}}"><img src="{{tracks.13.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{tracks.13.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
{% endif %} {% endif %}
{% endwith %}
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% else %}
<p class="text-muted">No data for this period</p>
{% endif %}
</div> </div>
{% endwith %}
{% endfor %}
</div> </div>
{% endfor %}
</div> </div>
<div class="row"> <div class="row">
<h2>Top TV Shows</h2> <h2>📺 Top TV Shows</h2>
<ul class="nav nav-tabs" id="tvshowTab" role="tablist"> <ul class="nav nav-tabs" id="tvshowTab" role="tablist">
{% for key, name in chart_keys.items %} {% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}" <button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
id="tvshow-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#tvshow-{{key}}" id="tvshow-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#tvshow-{{key}}"
type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button> type="button" role="tab">{{name}}</button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="tab-content" id="tvshowTabContent" class="maloja-chart"> <div class="tab-content" id="tvshowTabContent" class="maloja-chart">
{% for key, shows in tv_show_charts.items %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="tvshow-{{key}}" role="tabpanel" aria-labelledby="tvshow-{{key}}-tab">
<div style="display:block">
{% if shows.0 %}
<div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{shows.0.name}} ({{shows.0.num_scrobbles}} episodes)</div>
{% if shows.0.cover_image %}
<a href="{{shows.0.get_absolute_url}}"><img lt="{{shows.0.name}}" src="{{shows.0.cover_medium.url}}" width="300px" height="300px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.0.get_absolute_url}}"><img lt="{{shows.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px" height="300px" style="object-fit: cover"></a>
{% endif %}
</div>
</div>
{% endif %}
{% if shows.1 %}
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#2 {{shows.1.name}} ({{shows.1.num_scrobbles}})</div>
{% if shows.1.cover_image %}
<a href="{{shows.1.get_absolute_url}}"><img lt="{{shows.1.name}}" src="{{shows.1.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{shows.2.name}} ({{shows.2.num_scrobbles}})</div>
{% if shows.2.cover_image %}
<a href="{{shows.2.get_absolute_url}}"><img lt="{{shows.2.name}}" src="{{shows.2.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{shows.3.name}} ({{shows.3.num_scrobbles}})</div>
{% if shows.3.cover_image %}
<a href="{{shows.3.get_absolute_url}}"><img lt="{{shows.3.name}}" src="{{shows.3.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{shows.4.name}} ({{shows.4.num_scrobbles}})</div>
{% if shows.4.cover_image %}
<a href="{{shows.4.get_absolute_url}}"><img lt="{{shows.4.name}}" src="{{shows.4.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if shows.5 %}
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#6 {{shows.5.name}}</div>
{% if shows.5.cover_image %}
<a href="{{shows.5.get_absolute_url}}"><img src="{{shows.5.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#7 {{shows.6.name}}</div>
{% if shows.6.cover_image %}
<a href="{{shows.6.get_absolute_url}}"><img src="{{shows.6.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#8 {{shows.7.name}}</div>
{% if shows.7.cover_image %}
<a href="{{shows.7.get_absolute_url}}"><img src="{{shows.7.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#9 {{shows.8.name}}</div>
{% if shows.8.cover_image %}
<a href="{{shows.8.get_absolute_url}}"><img src="{{shows.8.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#10 {{shows.9.name}}</div>
{% if shows.9.cover_image %}
<a href="{{shows.9.get_absolute_url}}"><img src="{{shows.9.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#11 {{shows.10.name}}</div>
{% if shows.10.cover_image %}
<a href="{{shows.10.get_absolute_url}}"><img src="{{shows.10.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#12 {{shows.11.name}}</div>
{% if shows.11.cover_image %}
<a href="{{shows.11.get_absolute_url}}"><img src="{{shows.11.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#13 {{shows.12.name}}</div>
{% if shows.12.cover_image %}
<a href="{{shows.12.get_absolute_url}}"><img src="{{shows.12.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#14 {{shows.13.name}}</div>
{% if shows.13.cover_image %}
<a href="{{shows.13.get_absolute_url}}"><img src="{{shows.13.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{shows.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="row">
<h2>Top YouTube Channels</h2>
<ul class="nav nav-tabs" id="youtubeChannelTab" role="tablist">
{% for key, name in chart_keys.items %} {% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation"> {% with maloja_charts.tv_series|get_item:key as shows %}
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}" <div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="tvshow-{{key}}" role="tabpanel">
id="youtubeChannel-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#youtubeChannel-{{key}}" {% if shows.0 %}
type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="youtubeChannelTabContent" class="maloja-chart">
{% for key, channels in youtube_channel_charts.items %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="youtubeChannel-{{key}}" role="tabpanel" aria-labelledby="youtubeChannel-{{key}}-tab">
<div style="display:block"> <div style="display:block">
{% if channels.0 %}
<div style="float:left;"> <div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0"> <div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{channels.0.name}} ({{channels.0.num_scrobbles}} videos)</div> <div class="caption">#1 {{shows.0.tv_series.name}}</div>
{% if channels.0.cover_image %} {% if shows.0.tv_series.cover_image %}
<a href="{{channels.0.get_absolute_url}}"><img lt="{{channels.0.name}}" src="{{channels.0.cover_medium.url}}" width="300px" height="300px" style="object-fit: cover"></a> <a href="{{shows.0.tv_series.get_absolute_url}}"><img alt="{{shows.0.tv_series.name}}" src="{{shows.0.tv_series.cover_medium.url}}" width="300px" height="300px" style="object-fit: cover"></a>
{% else %} {% else %}
<a href="{{channels.0.get_absolute_url}}"><img lt="{{channels.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px" height="300px" style="object-fit: cover"></a> <a href="{{shows.0.tv_series.get_absolute_url}}"><img alt="{{shows.0.tv_series.name}}" src="{% static "images/not-found.jpg" %}" width="300px" height="300px" style="object-fit: cover"></a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% if channels.1 %}
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
{% for i in "2345" %}
{% with shows|get_item:forloop.counter as show %}
{% if show %}
<div class="image-wrapper" style="width:50%"> <div class="image-wrapper" style="width:50%">
<div class="caption-medium">#2 {{channels.1.name}} ({{channels.1.num_scrobbles}})</div> <div class="caption-medium">#{{forloop.counter|add:1}} {{show.tv_series.name}}</div>
{% if channels.1.cover_image %} {% if show.tv_series.cover_image %}
<a href="{{channels.1.get_absolute_url}}"><img lt="{{channels.1.name}}" src="{{channels.1.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a> <a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %} {% else %}
<a href="{{channels.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a> <a href="{{show.tv_series.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{channels.2.name}} ({{channels.2.num_scrobbles}})</div>
{% if channels.2.cover_image %}
<a href="{{channels.2.get_absolute_url}}"><img lt="{{channels.2.name}}" src="{{channels.2.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{channels.3.name}} ({{channels.3.num_scrobbles}})</div>
{% if channels.3.cover_image %}
<a href="{{channels.3.get_absolute_url}}"><img lt="{{channels.3.name}}" src="{{channels.3.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{channels.4.name}} ({{channels.4.num_scrobbles}})</div>
{% if channels.4.cover_image %}
<a href="{{channels.4.get_absolute_url}}"><img lt="{{channels.4.name}}" src="{{channels.4.cover_small.url}}" width="150px" height="150px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px" height="150px" style="object-fit: cover"></a>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endwith %}
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
{% if channels.5 %}
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %}
{% with shows|get_item:forloop.counter|add:5 as show %}
{% if show %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#6 {{channels.5.name}}</div> <div class="caption-small">#{{forloop.counter|add:6}} {{show.tv_series.name}}</div>
{% if channels.5.cover_image %} {% if show.tv_series.cover_image %}
<a href="{{channels.5.get_absolute_url}}"><img src="{{channels.5.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a> <a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %} {% else %}
<a href="{{channels.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a> <a href="{{show.tv_series.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#7 {{channels.6.name}}</div>
{% if channels.6.cover_image %}
<a href="{{channels.6.get_absolute_url}}"><img src="{{channels.6.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#8 {{channels.7.name}}</div>
{% if channels.7.cover_image %}
<a href="{{channels.7.get_absolute_url}}"><img src="{{channels.7.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#9 {{channels.8.name}}</div>
{% if channels.8.cover_image %}
<a href="{{channels.8.get_absolute_url}}"><img src="{{channels.8.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#10 {{channels.9.name}}</div>
{% if channels.9.cover_image %}
<a href="{{channels.9.get_absolute_url}}"><img src="{{channels.9.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#11 {{channels.10.name}}</div>
{% if channels.10.cover_image %}
<a href="{{channels.10.get_absolute_url}}"><img src="{{channels.10.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#12 {{channels.11.name}}</div>
{% if channels.11.cover_image %}
<a href="{{channels.11.get_absolute_url}}"><img src="{{channels.11.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#13 {{channels.12.name}}</div>
{% if channels.12.cover_image %}
<a href="{{channels.12.get_absolute_url}}"><img src="{{channels.12.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %}
</div>
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#14 {{channels.13.name}}</div>
{% if channels.13.cover_image %}
<a href="{{channels.13.get_absolute_url}}"><img src="{{channels.13.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
<a href="{{channels.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px" height="100px" style="object-fit: cover"></a>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endwith %}
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% else %}
<p class="text-muted">No data for this period</p>
{% endif %}
</div> </div>
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endif %}

View File

@ -57,12 +57,7 @@
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="btn-group me-2"> <div class="btn-group me-2">
<form action="/admin/" method="get"> <a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
<button type="submit" class="btn btn-sm btn-outline-secondary">
<span data-feather="key"></span>
Admin
</button>
</form>
</div> </div>
<div class="btn-group me-2"> <div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %} {% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}

View File

@ -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.webpages.api.views import DomainViewSet, WebPageViewSet
from vrobbler.apps.people import urls as people_urls 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 # from vrobbler.apps.modern_ui import urls as modern_ui_urls
@ -170,6 +171,7 @@ urlpatterns = [
path("", include(scrobble_urls, namespace="scrobbles")), path("", include(scrobble_urls, namespace="scrobbles")),
path("", include(profiles_urls, namespace="profiles")), path("", include(profiles_urls, namespace="profiles")),
path("", include(people_urls, namespace="people")), path("", include(people_urls, namespace="people")),
path("", include(charts_urls, namespace="charts")),
path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"), path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"),
] ]
if settings.DEBUG: if settings.DEBUG: