Compare commits

..

3 Commits

Author SHA1 Message Date
86d1cf0d65 Bump version to 0.11.1 2023-02-26 23:27:17 -05:00
a0101bf1ae Add first pass at AudioDB fetching 2023-02-26 23:26:51 -05:00
457afdc9ef Big fix to aggregation 2023-02-26 22:21:49 -05:00
20 changed files with 706 additions and 372 deletions

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "0.11.0" version = "0.11.1"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -78,13 +78,13 @@ def week_of_scrobbles(
media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE) media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE)
for day in range(6, -1, -1): for day in range(6, -1, -1):
start = start - timedelta(days=day) start_day = start - timedelta(days=day)
end = datetime.combine(start, datetime.max.time(), now.tzinfo) end = datetime.combine(start_day, datetime.max.time(), now.tzinfo)
day_of_week = start.strftime('%A') day_of_week = start_day.strftime('%A')
scrobble_day_dict[day_of_week] = base_qs.filter( scrobble_day_dict[day_of_week] = base_qs.filter(
media_filter, media_filter,
timestamp__gte=start, timestamp__gte=start_day,
timestamp__lte=end, timestamp__lte=end,
played_to_completion=True, played_to_completion=True,
).count() ).count()
@ -93,7 +93,7 @@ def week_of_scrobbles(
def top_tracks( def top_tracks(
user: "User", filter: str = "today", limit: int = 15 user: "User", filter: str = "today", limit: int = 30
) -> List["Track"]: ) -> List["Track"]:
now = timezone.now() now = timezone.now()
@ -109,7 +109,9 @@ def top_tracks(
starting_day_of_current_month = now.date().replace(day=1) starting_day_of_current_month = now.date().replace(day=1)
starting_day_of_current_year = now.date().replace(month=1, day=1) starting_day_of_current_year = now.date().replace(month=1, day=1)
time_filter = Q(scrobble__timestamp__gte=start_of_today) time_filter = Q()
if filter == "today":
time_filter = Q(scrobble__timestamp__gte=start_of_today)
if filter == "week": if filter == "week":
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week) time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week)
if filter == "month": if filter == "month":
@ -119,7 +121,13 @@ def top_tracks(
return ( return (
Track.objects.filter(time_filter) Track.objects.filter(time_filter)
.annotate(num_scrobbles=Count("scrobble", distinct=True)) .annotate(
num_scrobbles=Count(
"scrobble",
filter=Q(scrobble__played_to_completion=True),
distinct=True,
)
)
.order_by("-num_scrobbles")[:limit] .order_by("-num_scrobbles")[:limit]
) )
@ -143,8 +151,14 @@ def top_artists(
return ( return (
Artist.objects.filter(time_filter) Artist.objects.filter(time_filter)
.annotate(num_scrobbles=Count("track__scrobble", distinct=True)) .annotate(
.order_by("-num_scrobbles")[:limit] num_scrobbles=Count(
"track__scrobble",
filter=Q(track__scrobble__played_to_completion=True),
distinct=True,
)
)
.order_by("-num_scrobbles")
) )

View File

@ -0,0 +1,28 @@
# Generated by Django 4.1.5 on 2023-02-27 03:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0009_alter_track_musicbrainz_id_and_more'),
]
operations = [
migrations.AddField(
model_name='artist',
name='biography',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='artist',
name='theaudiodb_genre',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='artist',
name='theaudiodb_mood',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.5 on 2023-02-27 04:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0010_artist_biography_artist_theaudiodb_genre_and_more'),
]
operations = [
migrations.AddField(
model_name='artist',
name='thumbnail',
field=models.ImageField(
blank=True, null=True, upload_to='artist/'
),
),
]

View File

@ -1,15 +1,18 @@
import logging import logging
from tempfile import NamedTemporaryFile
from typing import Dict, Optional from typing import Dict, Optional
from urllib.request import urlopen
from uuid import uuid4 from uuid import uuid4
import musicbrainzngs import musicbrainzngs
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import File
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin from scrobbles.mixins import ScrobblableMixin
from scrobbles.theaudiodb import lookup_artist_from_tadb
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -18,7 +21,11 @@ BNULL = {"blank": True, "null": True}
class Artist(TimeStampedModel): class Artist(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
biography = models.TextField(**BNULL)
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL)
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
class Meta: class Meta:
unique_together = [['name', 'musicbrainz_id']] unique_together = [['name', 'musicbrainz_id']]
@ -53,6 +60,22 @@ class Artist(TimeStampedModel):
return ChartRecord.objects.filter(track__artist=self).order_by('-year') return ChartRecord.objects.filter(track__artist=self).order_by('-year')
def fix_metadata(self):
tadb_info = lookup_artist_from_tadb(self.name)
if not tadb_info:
logger.warn(f"No response from TADB for artist {self.name}")
return
self.biography = tadb_info['biography']
self.theaudiodb_genre = tadb_info['genre']
self.theaudiodb_mood = tadb_info['mood']
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(urlopen(tadb_info['thumb_url']).read())
img_temp.flush()
img_filename = f"{self.name}_{self.uuid}.jpg"
self.thumbnail.save(img_filename, File(img_temp))
class Album(TimeStampedModel): class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

View File

@ -1,6 +1,8 @@
import logging import logging
import re import re
from musicbrainzngs.caa import musicbrainz
from scrobbles.musicbrainz import ( from scrobbles.musicbrainz import (
lookup_album_dict_from_mb, lookup_album_dict_from_mb,
lookup_artist_from_mb, lookup_artist_from_mb,
@ -21,20 +23,20 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip() name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if 'featuring' in name.lower(): if 'featuring' in name.lower():
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip() name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
if '&' in name.lower():
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
artist_dict = lookup_artist_from_mb(name) artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict['id'] mbid = mbid or artist_dict['id']
logger.debug(f'Looking up artist {name} and mbid: {mbid}') logger.debug(f'Looking up artist {name} and mbid: {mbid}')
artist, created = Artist.objects.get_or_create( artist = Artist.objects.filter(musicbrainz_id=mbid).first()
name=name, musicbrainz_id=mbid if not artist:
) artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
logger.debug(
logger.debug(f"Cleaning artist {name} with {artist_dict['name']}") f"Created artist {artist.name} ({artist.musicbrainz_id}) "
# Clean up bad names in our DB with MB names )
# if artist.name != artist_dict["name"]: artist.fix_metadata()
# artist.name = artist_dict["name"]
# artist.save(update_fields=["name"])
return artist return artist
@ -78,27 +80,22 @@ def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
def get_or_create_track( def get_or_create_track(
title: str, title: str,
mbid: str,
artist: Artist, artist: Artist,
album: Album, album: Album,
mbid: str = None,
run_time=None, run_time=None,
run_time_ticks=None, run_time_ticks=None,
) -> Track: ) -> Track:
track = None track = None
if mbid:
track = Track.objects.filter(
musicbrainz_id=mbid,
).first()
if not track:
track = Track.objects.filter(
title=title, artist=artist, album=album
).first()
if not mbid: if not mbid:
mbid = lookup_track_from_mb( mbid = lookup_track_from_mb(
title, artist.musicbrainz_id, album.musicbrainz_id title,
artist.musicbrainz_id,
album.musicbrainz_id,
)['id'] )['id']
track = Track.objects.filter(musicbrainz_id=mbid).first()
if not track: if not track:
track = Track.objects.create( track = Track.objects.create(
title=title, title=title,

View File

@ -47,6 +47,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"user", "user",
"rank", "rank",
"count",
"year", "year",
"week", "week",
"month", "month",

View File

@ -0,0 +1,19 @@
import pytz
from django.utils import timezone
from scrobbles.models import Scrobble
def now_playing(request):
user = request.user
now = timezone.now()
if user.is_authenticated:
if user.profile:
timezone.activate(pytz.timezone(user.profile.timezone))
now = timezone.localtime(timezone.now())
return {
'now_playing_list': Scrobble.objects.filter(
in_progress=True,
is_paused=False,
user=user,
)
}

View File

@ -2,18 +2,19 @@ import calendar
import logging import logging
from uuid import uuid4 from uuid import uuid4
from books.models import Book
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from music.models import Artist, Track from music.models import Artist, Track
from books.models import Book
from podcasts.models import Episode from podcasts.models import Episode
from profiles.utils import now_user_timezone
from scrobbles.lastfm import LastFM from scrobbles.lastfm import LastFM
from scrobbles.utils import check_scrobble_for_finish 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.scrobbles.stats import build_charts from vrobbler.apps.scrobbles.stats import build_charts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,6 +36,24 @@ class BaseFileImportMixin(TimeStampedModel):
def __str__(self): def __str__(self):
return f"Scrobble import {self.id}" return f"Scrobble import {self.id}"
@property
def human_start(self):
start = "Unknown"
if self.processing_started:
start = self.processing_started.strftime('%B %d, %Y at %H:%M')
return start
@property
def import_type(self) -> str:
class_name = self.__class__.__name__
if class_name == 'AudioscrobblerTSVImport':
return "Audioscrobbler"
if class_name == 'KoReaderImport':
return "KoReader"
if self.__class__.__name__ == 'LastFMImport':
return "LastFM"
return "Generic"
def process(self, force=False): def process(self, force=False):
logger.warning("Process not implemented") logger.warning("Process not implemented")
@ -99,6 +118,14 @@ class KoReaderImport(BaseFileImportMixin):
class Meta: class Meta:
verbose_name = "KOReader Import" verbose_name = "KOReader Import"
def __str__(self):
return f"KoReader import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:koreader-import-detail', kwargs={'slug': self.uuid}
)
def get_path(instance, filename): def get_path(instance, filename):
extension = filename.split('.')[-1] extension = filename.split('.')[-1]
uuid = instance.uuid uuid = instance.uuid
@ -127,6 +154,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
class Meta: class Meta:
verbose_name = "AudioScrobbler TSV Import" verbose_name = "AudioScrobbler TSV Import"
def __str__(self):
return f"Audioscrobbler import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:tsv-import-detail', kwargs={'slug': self.uuid}
)
def get_path(instance, filename): def get_path(instance, filename):
extension = filename.split('.')[-1] extension = filename.split('.')[-1]
uuid = instance.uuid uuid = instance.uuid
@ -159,6 +194,14 @@ class LastFmImport(BaseFileImportMixin):
class Meta: class Meta:
verbose_name = "Last.FM Import" verbose_name = "Last.FM Import"
def __str__(self):
return f"LastFM import on {self.human_start}"
def get_absolute_url(self):
return reverse(
'scrobbles:lastfm-import-detail', kwargs={'slug': self.uuid}
)
def process(self, import_all=False): def process(self, import_all=False):
"""Import scrobbles found on LastFM""" """Import scrobbles found on LastFM"""
if self.processed_finished: if self.processed_finished:

View File

@ -89,10 +89,6 @@ def mopidy_scrobble_track(
"mopidy_status": data_dict.get("status"), "mopidy_status": data_dict.get("status"),
} }
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data) scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
return scrobble return scrobble
@ -130,7 +126,7 @@ def jellyfin_scrobble_track(
) )
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them # Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress: if null_position_on_progress:
logger.error("No playback position tick from Jellyfin, aborting") logger.error("No playback position tick from Jellyfin, aborting")
return return
@ -152,7 +148,6 @@ def jellyfin_scrobble_track(
) )
track = get_or_create_track( track = get_or_create_track(
title=data_dict.get("Name"), title=data_dict.get("Name"),
mbid=data_dict.get(JELLYFIN_POST_KEYS["TRACK_MB_ID"]),
artist=artist, artist=artist,
album=album, album=album,
run_time_ticks=run_time_ticks, run_time_ticks=run_time_ticks,

View File

@ -137,4 +137,6 @@ def build_charts(
if model_str == 'Artist': if model_str == 'Artist':
chart_record['artist'] = result chart_record['artist'] = result
chart_records.append(ChartRecord(**chart_record)) chart_records.append(ChartRecord(**chart_record))
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True) ChartRecord.objects.bulk_create(
chart_records, ignore_conflicts=True, batch_size=500
)

View File

@ -0,0 +1,33 @@
import json
import logging
import requests
from django.conf import settings
THEAUDIODB_API_KEY = getattr(settings, "THEAUDIODB_API_KEY")
SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/search.php?s="
logger = logging.getLogger(__name__)
def lookup_artist_from_tadb(name: str) -> dict:
artist_info = {}
response = requests.get(SEARCH_URL + name)
if response.status_code != 200:
logger.warn(f"Bad response from TADB: {response.status_code}")
return {}
if not response.content:
logger.warn(f"Bad content from TADB: {response.content}")
return {}
results = json.loads(response.content)
artist = results['artists'][0]
artist_info['biography'] = artist['strBiographyEN']
artist_info['genre'] = artist['strGenre']
artist_info['mood'] = artist['strMood']
artist_info['thumb_url'] = artist['strArtistThumb']
return artist_info

View File

@ -42,6 +42,26 @@ urlpatterns = [
name='mopidy-webhook', name='mopidy-webhook',
), ),
path('export/', views.export, name='export'), path('export/', views.export, name='export'),
path(
'imports/',
views.ScrobbleImportListView.as_view(),
name='import-detail',
),
path(
'imports/tsv/<slug:slug>/',
views.ScrobbleTSVImportDetailView.as_view(),
name='tsv-import-detail',
),
path(
'imports/lastfm/<slug:slug>/',
views.ScrobbleLastFMImportDetailView.as_view(),
name='lastfm-import-detail',
),
path(
'imports/koreader/<slug:slug>/',
views.ScrobbleKoReaderImportDetailView.as_view(),
name='koreader-import-detail',
),
path( path(
'charts/', 'charts/',
views.ChartRecordView.as_view(), views.ChartRecordView.as_view(),

View File

@ -1,7 +1,8 @@
import calendar import calendar
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from django.db.models.query import QuerySet
import pytz import pytz
from django.conf import settings from django.conf import settings
@ -13,7 +14,7 @@ from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, TemplateView from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from music.aggregators import ( from music.aggregators import (
@ -70,17 +71,7 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
user = self.request.user user = self.request.user
now = timezone.now()
if user.is_authenticated: if user.is_authenticated:
if user.profile:
timezone.activate(pytz.timezone(user.profile.timezone))
now = timezone.localtime(timezone.now())
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
is_paused=False,
timestamp__lte=now,
user=user,
)
completed_for_user = Scrobble.objects.filter( completed_for_user = Scrobble.objects.filter(
played_to_completion=True, user=user played_to_completion=True, user=user
@ -97,19 +88,16 @@ class RecentScrobbleList(ListView):
sport_event__isnull=False sport_event__isnull=False
).order_by('-timestamp')[:15] ).order_by('-timestamp')[:15]
# data['top_daily_tracks'] = top_tracks()
data['top_weekly_tracks'] = top_tracks(user, filter='week')
data['top_monthly_tracks'] = top_tracks(user, filter='month')
# data['top_daily_artists'] = top_artists()
data['top_weekly_artists'] = top_artists(user, filter='week')
data['top_monthly_artists'] = top_artists(user, filter='month')
data["weekly_data"] = week_of_scrobbles(user=user) data["weekly_data"] = week_of_scrobbles(user=user)
data['counts'] = scrobble_counts(user) data['counts'] = scrobble_counts(user)
data['imdb_form'] = ScrobbleForm data['imdb_form'] = ScrobbleForm
data['export_form'] = ExportScrobbleForm data['export_form'] = ExportScrobbleForm
data['active_imports'] = AudioScrobblerTSVImport.objects.filter(
processing_started__isnull=False,
processed_finished__isnull=True,
user=self.request.user,
)
return data return data
def get_queryset(self): def get_queryset(self):
@ -118,6 +106,57 @@ class RecentScrobbleList(ListView):
).order_by('-timestamp')[:15] ).order_by('-timestamp')[:15]
class ScrobbleImportListView(TemplateView):
template_name = "scrobbles/import_list.html"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data['object_list'] = []
context_data["tsv_imports"] = AudioScrobblerTSVImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
context_data["koreader_imports"] = KoReaderImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
context_data["lastfm_imports"] = LastFmImport.objects.filter(
user=self.request.user,
).order_by('-processing_started')
return context_data
class BaseScrobbleImportDetailView(DetailView):
slug_field = 'uuid'
template_name = "scrobbles/import_detail.html"
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
title = "Generic Scrobble Import"
if self.model == KoReaderImport:
title = "KoReader Import"
if self.model == AudioScrobblerTSVImport:
title = "Audioscrobbler TSV Import"
if self.model == LastFmImport:
title = "LastFM Import"
context_data['title'] = title
return context_data
class ScrobbleKoReaderImportDetailView(BaseScrobbleImportDetailView):
model = KoReaderImport
class ScrobbleTSVImportDetailView(BaseScrobbleImportDetailView):
model = AudioScrobblerTSVImport
class ScrobbleLastFMImportDetailView(BaseScrobbleImportDetailView):
model = LastFmImport
class ManualScrobbleView(FormView): class ManualScrobbleView(FormView):
form_class = ScrobbleForm form_class = ScrobbleForm
template_name = 'scrobbles/manual_form.html' template_name = 'scrobbles/manual_form.html'
@ -366,44 +405,116 @@ def export(request):
class ChartRecordView(TemplateView): class ChartRecordView(TemplateView):
template_name = 'scrobbles/chart_index.html' template_name = 'scrobbles/chart_index.html'
def get_context_data(self, **kwargs): @staticmethod
context_data = super().get_context_data(**kwargs) def get_media_filter(media_type: str = "Track"):
media_filter = Q()
date = self.request.GET.get('date') if media_type == 'Track':
media_type = self.request.GET.get('media') media_filter = Q(track__isnull=False)
params = {}
if not media_type:
media_type = 'Track'
context_data['media_type'] = media_type
media_filter = Q(track__isnull=False)
if media_type == 'Video':
media_filter = Q(video__isnull=False)
if media_type == 'Artist': if media_type == 'Artist':
media_filter = Q(artist__isnull=False) media_filter = Q(artist__isnull=False)
if media_type == 'Series':
media_filter = Q(series__isnull=False)
if media_type == 'Video':
media_filter = Q(video__isnull=False)
return media_filter
year = timezone.now().year def get_chart_records(self, media_type: str = "Track", **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 = "Track"
) -> QuerySet:
chart = QuerySet()
now = timezone.now()
if period == "all_time":
chart = self.get_chart_records(media_type=media)
if period == "today":
chart = self.get_chart_records(
media_type=media,
day=now.day,
month=now.month,
year=now.year,
)
if period == "week":
chart = self.get_chart_records(
media_type=media,
year=now.year,
week=now.isocalendar()[1],
)
if period == "month":
chart = self.get_chart_records(
media_type=media,
year=now.year,
month=now.month,
)
if period == "year":
chart = self.get_chart_records(
media_type=media,
year=now.year,
)
return chart[: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')
user = self.request.user
params = {}
context_data['artist_charts'] = {}
if not date:
context_data['artist_charts'] = {
"today": top_artists(user, filter="today")[:30],
"week": top_artists(user, filter="week")[:30],
"month": top_artists(user, filter="month")[:30],
"all": top_artists(user),
}
context_data['track_charts'] = {
"today": top_tracks(user, filter="today")[:30],
"week": top_tracks(user, filter="week")[:30],
"month": top_tracks(user, filter="month")[:30],
"all": top_tracks(user),
}
return context_data
now = timezone.now()
year = now.year
params = {'year': year} params = {'year': year}
name = f"Chart for {year}" name = f"Chart for {year}"
if not date:
date = timezone.now().strftime("%Y-%m-%d")
date_params = date.split('-') date_params = date.split('-')
year = int(date_params[0]) year = int(date_params[0])
in_progress = False
if len(date_params) == 2: if len(date_params) == 2:
if 'W' in date_params[1]: if 'W' in date_params[1]:
week = int(date_params[1].strip('W"')) week = int(date_params[1].strip('W"'))
params['week'] = week params['week'] = week
r = datetime.strptime(date + '-1', "%Y-W%W-%w").strftime( start = datetime.strptime(date + "-1", "%Y-W%W-%w").replace(
'Week of %B %d, %Y' tzinfo=pytz.utc
) )
name = f"Chart for {r}" 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: else:
month = int(date_params[1]) month = int(date_params[1])
params['month'] = month params['month'] = month
month_str = calendar.month_name[month] month_str = calendar.month_name[month]
name = f"Chart for {month_str} {year}" name = f"Chart for {month_str} {year}"
in_progress = now.month == month and now.year == year
if len(date_params) == 3: if len(date_params) == 3:
month = int(date_params[1]) month = int(date_params[1])
day = int(date_params[2]) day = int(date_params[2])
@ -411,7 +522,11 @@ class ChartRecordView(TemplateView):
params['day'] = day params['day'] = day
month_str = calendar.month_name[month] month_str = calendar.month_name[month]
name = f"Chart for {month_str} {day}, {year}" 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(media_type)
charts = ChartRecord.objects.filter( charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params media_filter, user=self.request.user, **params
).order_by("rank") ).order_by("rank")
@ -424,6 +539,10 @@ class ChartRecordView(TemplateView):
media_filter, user=self.request.user, **params media_filter, user=self.request.user, **params
).order_by("rank") ).order_by("rank")
context_data['object_list'] = charts if in_progress:
# TODO recalculate
...
context_data['name'] = name context_data['name'] = name
context_data['in_progress'] = in_progress
return context_data return context_data

View File

@ -2,6 +2,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
import dj_database_url import dj_database_url
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv from dotenv import load_dotenv
@ -50,13 +51,9 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
# Used to dump data coming from srobbling sources, helpful for building new inputs # Used to dump data coming from srobbling sources, helpful for building new inputs
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False) DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2") THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
THESPORTSDB_BASE_URL = os.getenv( THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
)
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "") TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY") LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY") LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
@ -71,6 +68,10 @@ CSRF_TRUSTED_ORIGINS = [
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None) REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
if REDIS_URL:
print(f"Sending tasks to redis@{REDIS_URL.split('@')[-1]}")
else:
print("Eagerly running all tasks")
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False) CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/" CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
@ -135,6 +136,7 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"videos.context_processors.video_lists", "videos.context_processors.video_lists",
"music.context_processors.music_lists", "music.context_processors.music_lists",
"scrobbles.context_processors.now_playing",
], ],
}, },
}, },
@ -150,10 +152,18 @@ DATABASES = {
conn_max_age=600, conn_max_age=600,
), ),
} }
if TESTING: if TESTING:
DATABASES = { DATABASES = {
"default": dj_database_url.config(default="sqlite:///testdb.sqlite3") "default": dj_database_url.config(default="sqlite:///testdb.sqlite3")
} }
db_str = ""
if 'sqlite' in DATABASES['default']['ENGINE']:
db_str = f"Connected to sqlite@{DATABASES['default']['NAME']}"
if 'postgresql' in DATABASES['default']['ENGINE']:
db_str = f"Connected to postgres@{DATABASES['default']['HOST']}/{DATABASES['default']['NAME']}"
if db_str:
print(db_str)
CACHES = { CACHES = {

View File

@ -204,25 +204,6 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% if now_playing_list and user.is_authenticated %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div>
{{scrobble.media_obj.title}}<br/>
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
<small>{{scrobble.timestamp|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
</div>
<hr/>
{% endfor %}
</ul>
{% endif %}
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" aria-current="page" href="/"> <a class="nav-link active" aria-current="page" href="/">
@ -271,6 +252,40 @@
</ul> </ul>
{% block extra_nav %} {% block extra_nav %}
{% endblock %} {% endblock %}
<hr/>
{% if now_playing_list and user.is_authenticated %}
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div>
{% if scrobble.media_obj.album.cover_image %}
<td><img src="{{scrobble.track.album.cover_image.url}}" width=120 height=120
style="border:1px solid black; " /></td><br/>
{% endif %}
{{scrobble.media_obj.title}}<br/>
{% if scrobble.media_obj.subtitle %}<em>{{scrobble.media_obj.subtitle}}</em><br/>{% endif %}
<small>{{scrobble.timestamp|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
<hr />
</div>
{% endfor %}
</ul>
<hr/>
{% endif %}
{% if active_imports %}
{% for import in active_imports %}
<ul style="padding-right:10px;">
<li>Import in progress ({{import.processing_started|naturaltime}})</li>
</ul>
{% endfor %}
{% endif %}
</div> </div>
</nav> </nav>

View File

@ -1,30 +1,94 @@
{% extends "base_list.html" %} {% extends "base_list.html" %}
{% block title %}{{name}}{% endblock %}
{% block lists %} {% block lists %}
<h2>{{name}}</h2>
<div class="row"> <div class="row">
<div class="col-md"> <h2>Top Artists</h2>
<div class="table-responsive">
<table class="table table-striped table-sm"> <ul class="nav nav-tabs" id="artistTab" role="tablist">
<thead> {% for chart_name in artist_charts.keys %}
<tr> <li class="nav-item" role="presentation">
<th scope="col">Rank</th> <button class="nav-link {% if forloop.first %}active{% endif %}" id="artist-{{chart_name}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{chart_name}}"
<th scope="col">{{media_type}}</th> type="button" role="tab" aria-controls="home" aria-selected="true">
<th scope="col">Scrobbles</th> {{chart_name}}
</tr> </button>
</thead> </li>
<tbody> {% endfor %}
{% for chartrecord in object_list %} </ul>
<tr>
<td>{{chartrecord.rank}}</td> <div class="tab-content" id="artistTabContent">
<td><a href="{{chartrecord.media_obj.get_absolute_url}}">{{chartrecord.media_obj}}</a></td> {% for chart_name, artists in artist_charts.items %}
<td>{{chartrecord.count}}</td> <div class="tab-pane fade {% if forloop.first %}show active{% endif %}" id="artist-{{chart_name}}" role="tabpanel"
<td></td> aria-labelledby="artist-{[chart}}-tab">
</tr> <div class="table-responsive">
{% endfor %} <table class="table table-striped table-sm">
</tbody> <thead>
</table> <tr>
<th scope="col">Rank</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for artist in artists %}
<tr>
<td>{{artist.rank}}</td>
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
<td>{{artist.num_scrobbles}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
<div class="row">
<h2>Top Tracks</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for chart_name in track_charts.keys %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}" id="track-{{chart_name}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{chart_name}}"
type="button" role="tab" aria-controls="home" aria-selected="true">
{{chart_name}}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="trackTabContent">
{% for chart_name, tracks in track_charts.items %}
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}" id="track-{{chart_name}}" role="tabpanel"
aria-labelledby="track-{[chart_name}}-tab">
<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">Track</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td>{{track.rank}}</td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
<td><a href="{{track.get_absolute_url}}">{{track.title}}</a></td>
<td>{{track.num_scrobbles}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_detail.html" %}
{% block title %}{{title}}{% endblock %}
{% block details %}
<div class="row">
<div class="col-md">
<p>Import started: {{object.processing_started}}</p>
<p>Import finished: {{object.processed_finished}}</p>
<p>Imported {{object.process_count}} scrobbles</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "base_list.html" %}
{% block title %}Scrobble Imports{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Date</th>
<th scope="col">Scrobbles Imported</th>
<th scope="col">Finished</th>
</tr>
</thead>
<tbody>
{% for obj in tsv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
{% for obj in lastfm_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
{% for obj in koreader_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.import_type}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.process_count}}</td>
<td>{{obj.processed_finished}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -46,260 +46,131 @@
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p> This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <ul class="nav nav-tabs" id="myTab" role="tablist">
<ul class="nav nav-tabs" id="myTab" role="tablist"> <li class="nav-item" role="presentation">
<li class="nav-item" role="presentation"> <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened"
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" </li>
aria-selected="true">Weekly Artists</button> <li class="nav-item" role="presentation">
</li> <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched"
<li class="nav-item" role="presentation"> type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
<button class="nav-link" id="artist-month-tab" data-bs-toggle="tab" </li>
data-bs-target="#artists-month" type="button" role="tab" aria-controls="home" <li class="nav-item" role="presentation">
aria-selected="true">Monthly Artists</button> <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted"
</li> type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
<li class="nav-item" role="presentation"> </li>
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week" <li class="nav-item" role="presentation">
type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
Tracks</button> type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li> </li>
<li class="nav-item" role="presentation"> </ul>
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-month"
type="button" role="tab" aria-controls="profile" aria-selected="false">Monthly
Tracks</button>
</li>
</ul>
<div class="tab-content" id="myTabContent"> <div class="tab-content" id="myTabContent2">
<div class="tab-pane fade show active" id="artists-week" role="tabpanel" <div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
aria-labelledby="artists-week-tab"> aria-labelledby="latest-listened-tab">
<h2>Top artists this week</h2> <h2>Latest listened</h2>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>
<th scope="col">#</th> <th scope="col">Time</th>
<th scope="col">Artist</th> <th scope="col">Album</th>
</tr> <th scope="col">Track</th>
</thead> <th scope="col">Artist</th>
<tbody> </tr>
{% for artist in top_weekly_artists %} </thead>
<tr> <tbody>
<td>{{artist.num_scrobbles}}</td> {% for scrobble in object_list %}
<td>{{artist.name}}</td> <tr>
</tr> <td>{{scrobble.timestamp|naturaltime}}</td>
{% endfor %} {% if scrobble.track.album.cover_image %}
</tbody> <td><img src="{{scrobble.track.album.cover_image.url}}" width=25 height=25
</table> style="border:1px solid black;" /></td>
</div> {% else %}
<td>{{scrobble.track.album.name}}</td>
{% endif %}
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<div class="tab-pane fade show" id="tracks-week" role="tabpanel" aria-labelledby="tracks-week-tab">
<h2>Top tracks this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in top_weekly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="tracks-month" role="tabpanel"
aria-labelledby="tracks-month-tab">
<h2>Top tracks this month</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in top_monthly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show " id="artists-month" role="tabpanel"
aria-labelledby="artists-month-tab">
<h2>Top artists this month</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_monthly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
</div>
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab"
data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home"
aria-selected="true">Tracks</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched"
type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab"
data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile"
aria-selected="false">Podcasts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li>
</ul>
<div class="tab-content" id="myTabContent2"> <div class="tab-pane fade show" id="latest-watched" role="tabpanel"
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel" aria-labelledby="latest-watched-tab">
aria-labelledby="latest-listened-tab"> <h2>Latest watched</h2>
<h2>Latest listened</h2> <div class="table-responsive">
<div class="table-responsive"> <table class="table table-striped table-sm">
<table class="table table-striped table-sm"> <thead>
<thead> <tr>
<tr> <th scope="col">Time</th>
<th scope="col">Time</th> <th scope="col">Title</th>
<th scope="col">Album</th> <th scope="col">Series</th>
<th scope="col">Track</th> </tr>
<th scope="col">Artist</th> </thead>
</tr> <tbody>
</thead> {% for scrobble in video_scrobble_list %}
<tbody> <tr>
{% for scrobble in object_list %} <td>{{scrobble.timestamp|naturaltime}}</td>
<tr> <td>{% if scrobble.video.tv_series%}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{%endif %} {{scrobble.video.title}}</td>
<td>{{scrobble.timestamp|naturaltime}}</td> <td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}
{% if scrobble.track.album.cover_image %} </td>
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50 </tr>
style="border:1px solid black;" /></td> {% endfor %}
{% else %} </tbody>
<td>{{scrobble.track.album.name}}</td> </table>
{% endif %}
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div>
<div class="tab-pane fade show" id="latest-watched" role="tabpanel" <div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
aria-labelledby="latest-watched-tab"> <h2>Latest Sports</h2>
<h2>Latest watched</h2> <div class="table-responsive">
<div class="table-responsive"> <table class="table table-striped table-sm">
<table class="table table-striped table-sm"> <thead>
<thead> <tr>
<tr> <th scope="col">Date</th>
<th scope="col">Time</th> <th scope="col">Title</th>
<th scope="col">Title</th> <th scope="col">League</th>
<th scope="col">Series</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {% for scrobble in sport_scrobble_list %}
{% for scrobble in video_scrobble_list %} <tr>
<tr> <td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.timestamp|naturaltime}}</td> <td>{{scrobble.sport_event.title}}</td>
<td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td> <td>{{scrobble.sport_event.league.abbreviation}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %} </tr>
</td> {% endfor %}
</tr> </tbody>
{% endfor %} </table>
</tbody>
</table>
</div>
</div> </div>
</div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" <div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
aria-labelledby="latest-sports-tab"> aria-labelledby="latest-podcasted-tab">
<h2>Latest Sports</h2> <h2>Latest Podcasted</h2>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Title</th> <th scope="col">Title</th>
<th scope="col">League</th> <th scope="col">Podcast</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in sport_scrobble_list %} {% for scrobble in podcast_scrobble_list %}
<tr> <tr>
<td>{{scrobble.timestamp|naturaltime}}</td> <td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.sport_event.title}}</td> <td>{{scrobble.podcast_episode.title}}</td>
<td>{{scrobble.sport_event.league.abbreviation}}</td> <td>{{scrobble.podcast_episode.podcast}}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
aria-labelledby="latest-podcasted-tab">
<h2>Latest Podcasted</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Podcast</th>
</tr>
</thead>
<tbody>
{% for scrobble in podcast_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.podcast_episode.title}}</td>
<td>{{scrobble.podcast_episode.podcast}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}