Fix chart rank and periods

This commit is contained in:
2023-03-03 02:29:15 -05:00
parent 3c3e567573
commit 6e17e4ce0d
10 changed files with 237 additions and 46 deletions

View File

@ -1,5 +1,4 @@
from datetime import timedelta from django.db.models import Count
from django.utils import timezone
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 scrobbles.models import ChartRecord
@ -22,7 +21,6 @@ class TrackDetailView(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
context_data['charts'] = ChartRecord.objects.filter( context_data['charts'] = ChartRecord.objects.filter(
track=self.object, rank__in=[1, 2, 3] track=self.object, rank__in=[1, 2, 3]
) )
@ -34,7 +32,12 @@ class ArtistListView(generic.ListView):
paginate_by = 100 paginate_by = 100
def get_queryset(self): def get_queryset(self):
return super().get_queryset().order_by("name") return (
super()
.get_queryset()
.annotate(scrobble_count=Count('track__scrobble'))
.order_by("-scrobble_count")
)
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(
@ -50,6 +53,17 @@ class ArtistDetailView(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
artist = context_data['object']
rank = 1
tracks_ranked = []
scrobbles = artist.tracks.first().scrobble_count
for track in artist.tracks:
if scrobbles > track.scrobble_count:
rank += 1
tracks_ranked.append((rank, track))
scrobbles = track.scrobble_count
context_data['tracks_ranked'] = tracks_ranked
context_data['charts'] = ChartRecord.objects.filter( context_data['charts'] = ChartRecord.objects.filter(
artist=self.object, rank__in=[1, 2, 3] artist=self.object, rank__in=[1, 2, 3]
) )
@ -58,17 +72,14 @@ class ArtistDetailView(generic.DetailView):
class AlbumListView(generic.ListView): class AlbumListView(generic.ListView):
model = Album model = Album
paginate_by = 50
def get_queryset(self): def get_queryset(self):
return super().get_queryset().order_by("name") return (
super()
def get_context_data(self, *, object_list=None, **kwargs): .get_queryset()
context_data = super().get_context_data( .annotate(scrobble_count=Count('track__scrobble'))
object_list=object_list, **kwargs .order_by("-scrobble_count")
) )
context_data['view'] = self.request.GET.get('view')
return context_data
class AlbumDetailView(generic.DetailView): class AlbumDetailView(generic.DetailView):

View File

@ -1,23 +1,18 @@
import datetime
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
import calendar
from datetime import datetime, timedelta
# need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates # need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates
def to_user_timezone(date, profile): def to_user_timezone(date, profile):
timezone = profile.timezone if profile.timezone else settings.TIME_ZONE timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
return date.replace(tzinfo=pytz.timezone(settings.TIME_ZONE)).astimezone( return date.astimezone(pytz.timezone(timezone))
pytz.timezone(timezone)
)
def to_system_timezone(date, profile): def to_system_timezone(date):
timezone = profile.timezone if profile.timezone else settings.TIME_ZONE return date.astimezone(pytz.timezone(settings.TIME_ZONE))
return date.replace(tzinfo=pytz.timezone(timezone)).astimezone(
pytz.timezone(settings.TIME_ZONE)
)
def now_user_timezone(profile): def now_user_timezone(profile):
@ -25,9 +20,39 @@ def now_user_timezone(profile):
return timezone.localtime(timezone.now()) return timezone.localtime(timezone.now())
def now_system_timezone(): def start_of_day(dt, profile) -> datetime:
return ( """Get the start of the day in the profile's timezone"""
datetime.datetime.now() timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
.replace(tzinfo=pytz.timezone(settings.TIME_ZONE)) tzinfo = pytz.timezone(timezone)
.astimezone(pytz.timezone(settings.TIME_ZONE)) return datetime.combine(dt, datetime.min.time(), tzinfo)
)
def end_of_day(dt, profile) -> datetime:
"""Get the start of the day in the profile's timezone"""
timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
tzinfo = pytz.timezone(timezone)
return datetime.combine(dt, datetime.max.time(), tzinfo)
def start_of_week(dt, profile) -> datetime:
# TODO allow profile to set start of week
return start_of_day(dt, profile) - timedelta(dt.weekday())
def end_of_week(dt, profile) -> datetime:
# TODO allow profile to set start of week
return start_of_week(dt, profile) + timedelta(days=6)
def start_of_month(dt, profile) -> datetime:
return start_of_day(dt, profile).replace(day=1)
def end_of_month(dt, profile) -> datetime:
next_month = end_of_day(dt, profile).replace(day=28) + timedelta(days=4)
# subtracting the number of the current day brings us back one month
return next_month - timedelta(days=next_month.day)
def start_of_year(dt, profile) -> datetime:
return start_of_day(dt, profile).replace(month=1, day=1)

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-03-03 00:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0023_alter_audioscrobblertsvimport_options_and_more'),
]
operations = [
migrations.AddField(
model_name='chartrecord',
name='period_end',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='chartrecord',
name='period_start',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -1,4 +1,5 @@
import calendar import calendar
import datetime
import logging import logging
from uuid import uuid4 from uuid import uuid4
@ -15,6 +16,14 @@ from scrobbles.utils import check_scrobble_for_finish
from sports.models import SportEvent from sports.models import SportEvent
from videos.models import Series, Video from videos.models import Series, Video
from vrobbler.apps.profiles.utils import (
end_of_day,
end_of_month,
end_of_week,
start_of_day,
start_of_month,
start_of_week,
)
from vrobbler.apps.scrobbles.stats import build_charts from vrobbler.apps.scrobbles.stats import build_charts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -62,6 +71,7 @@ class BaseFileImportMixin(TimeStampedModel):
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
if not self.process_log: if not self.process_log:
logger.warning("No lines in process log found to undo") logger.warning("No lines in process log found to undo")
return return
@ -256,6 +266,26 @@ class ChartRecord(TimeStampedModel):
series = models.ForeignKey(Series, 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) artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, 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 @property
def media_obj(self): def media_obj(self):

View File

@ -6,8 +6,11 @@ from typing import Optional
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db.models import Count, Q, ExpressionWrapper, OuterRef, Subquery from django.contrib.auth import get_user_model
from django.db.models import Count, Q
from django.utils import timezone
User = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -102,11 +105,11 @@ def get_scrobble_count_qs(
def build_charts( def build_charts(
user: "User",
year: Optional[int] = None, year: Optional[int] = None,
month: Optional[int] = None, month: Optional[int] = None,
week: Optional[int] = None, week: Optional[int] = None,
day: Optional[int] = None, day: Optional[int] = None,
user=None,
model_str="Track", model_str="Track",
): ):
ChartRecord = apps.get_model( ChartRecord = apps.get_model(
@ -140,3 +143,94 @@ def build_charts(
ChartRecord.objects.bulk_create( ChartRecord.objects.bulk_create(
chart_records, ignore_conflicts=True, batch_size=500 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,13 +1,17 @@
import logging import logging
from celery import shared_task
from celery import shared_task
from django.contrib.auth import get_user_model
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
KoReaderImport, KoReaderImport,
LastFmImport, LastFmImport,
) )
from vrobbler.apps.scrobbles.stats import build_yesterdays_charts_for_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model()
@shared_task @shared_task
@ -35,3 +39,9 @@ def process_koreader_import(import_id):
logger.warn(f"KOReaderImport not found with id {import_id}") logger.warn(f"KOReaderImport not found with id {import_id}")
koreader_import.process() koreader_import.process()
@shared_task
def create_yesterdays_charts():
for user in User.objects.all():
build_yesterdays_charts_for_user(user)

View File

@ -46,17 +46,17 @@
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>
<th scope="col">Scrobbles</th>
<th scope="col">Album</th> <th scope="col">Album</th>
<th scope="col">Artist</th> <th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for album in object_list %} {% for album in object_list %}
<tr> <tr>
<td><a href="{{album.get_absolute_url}}">{{album}}</a></td>
<td><a href="{{album.artist.get_absolute_url}}">{{album.artist}}</a></td>
<td>{{album.scrobbles.count}}</td> <td>{{album.scrobbles.count}}</td>
<td><a href="{{album.get_absolute_url}}">{{album}}</a></td>
<td><a href="{{album.primary_artist.get_absolute_url}}">{{album.primary_artist}}</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -44,15 +44,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for track in object.tracks %} {% for track in tracks_ranked %}
<tr> <tr>
<td>{{rank}}#1</td> <td>#{{track.0}}</td>
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td> <td><a href="{{track.1.get_absolute_url}}">{{track.1.title}}</a></td>
<td><a href="{{track.album.get_absolute_url}}">{{track.album}}</a></td> <td><a href="{{track.1.album.get_absolute_url}}">{{track.1.album}}</a></td>
<td>{{track.scrobble_count}}</td> <td>{{track.1.scrobble_count}}</td>
<td> <td>
<div class="progress-bar" style="margin-right:5px;"> <div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{track.scrobble_count|mul:10}}%;"></span> <span class="progress-bar-fill" style="width: {{track.1.scrobble_count|mul:10}}%;"></span>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -45,17 +45,15 @@
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th> <th scope="col">Scrobbles</th>
<th scope="col">All time</th> <th scope="col">Artist</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for artist in object_list %} {% for artist in object_list %}
<tr> <tr>
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
<td>{{artist.scrobbles.count}}</td> <td>{{artist.scrobbles.count}}</td>
<td></td> <td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -24,17 +24,17 @@
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>
<th scope="col">Scrobbles</th>
<th scope="col">Track</th> <th scope="col">Track</th>
<th scope="col">Artist</th> <th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for track in object_list %} {% for track in object_list %}
<tr> <tr>
<td>{{track.scrobble_set.count}}</td>
<td><a href="{{track.get_absolute_url}}">{{track}}</a></td> <td><a href="{{track.get_absolute_url}}">{{track}}</a></td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td> <td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
<td>{{track.scrobble_set.count}}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>