From 49bf57dd587616b86b5703e95d20fe307900fb60 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sun, 22 Jan 2023 18:27:33 -0500 Subject: [PATCH] Copmletely rejigger sports to accomodate tennis --- vrobbler/apps/scrobbles/scrobblers.py | 2 +- vrobbler/apps/scrobbles/thesportsdb.py | 22 +- vrobbler/apps/sports/admin.py | 50 +++- .../migrations/0003_sport_league_sport.py | 71 +++++ ...ue_options_alter_sport_options_and_more.py | 254 ++++++++++++++++++ .../0005_remove_sportevent_season.py | 17 ++ .../0006_alter_sportevent_event_type.py | 22 ++ .../0007_sport_default_event_type.py | 22 ++ vrobbler/apps/sports/models.py | 185 ++++++++++--- vrobbler/apps/sports/utils.py | 11 + vrobbler/templates/base.html | 2 +- 11 files changed, 604 insertions(+), 54 deletions(-) create mode 100644 vrobbler/apps/sports/migrations/0003_sport_league_sport.py create mode 100644 vrobbler/apps/sports/migrations/0004_alter_league_options_alter_sport_options_and_more.py create mode 100644 vrobbler/apps/sports/migrations/0005_remove_sportevent_season.py create mode 100644 vrobbler/apps/sports/migrations/0006_alter_sportevent_event_type.py create mode 100644 vrobbler/apps/sports/migrations/0007_sport_default_event_type.py create mode 100644 vrobbler/apps/sports/utils.py diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 3783253..13880d1 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -119,7 +119,7 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict: "timestamp": parse(data_dict.get("UtcTimestamp")), "playback_position_ticks": playback_position_ticks, "playback_position": playback_position, - "source": data_dict.get("ClientName", "Jellyfin"), + "source": data_dict.get("ClientName", "Vrobbler"), "source_id": data_dict.get('MediaSourceId'), "jellyfin_status": jellyfin_status, } diff --git a/vrobbler/apps/scrobbles/thesportsdb.py b/vrobbler/apps/scrobbles/thesportsdb.py index 568d06f..39d40de 100644 --- a/vrobbler/apps/scrobbles/thesportsdb.py +++ b/vrobbler/apps/scrobbles/thesportsdb.py @@ -4,7 +4,7 @@ from dateutil.parser import parse from django.conf import settings from django.utils import timezone from pysportsdb import TheSportsDbClient -from sports.models import SportEvent +from sports.models import Sport logger = logging.getLogger(__name__) @@ -15,18 +15,23 @@ client = TheSportsDbClient(api_key=API_KEY) def lookup_event_from_thesportsdb(event_id: str) -> dict: event = client.lookup_event(event_id)['events'][0] + if not event or type(event) != dict: + return {} league = {} # client.lookup_league(league_id=event.get('idLeague')) - event_type = SportEvent.Type.GAME - run_time_seconds = 11700 - run_time_ticks = run_time_seconds * 1000 + event_type = "Game" + sport = Sport.objects.filter(thesportsdb_id=event.get('strSport')).first() + logger.debug(event) data_dict = { - "ItemType": event_type, - "Name": event.get('strEventAlternate'), + "ItemType": sport.default_event_type, + "Name": event.get('strEvent'), + "AltName": event.get('strEventAlternate'), "Start": parse(event.get('strTimestamp')), "Provider_thesportsdb": event.get('idEvent'), - "RunTime": run_time_seconds, - "RunTimeTicks": run_time_ticks, + "RunTime": sport.default_event_run_time, + "RunTimeTicks": sport.default_event_run_time_ticks, + "Sport": event.get('strSport'), + "Season": event.get('strSeason'), "LeagueId": event.get('idLeague'), "LeagueName": event.get('strLeague'), "HomeTeamId": event.get('idHomeTeam'), @@ -39,6 +44,7 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict: "UtcTimestamp": timezone.now().strftime('%Y-%m-%d %H:%M:%S.%f%z'), "IsPaused": False, "PlayedToCompletion": False, + "Source": "Vrobbler", } return data_dict diff --git a/vrobbler/apps/sports/admin.py b/vrobbler/apps/sports/admin.py index 6f6d8ef..9d3f0ae 100644 --- a/vrobbler/apps/sports/admin.py +++ b/vrobbler/apps/sports/admin.py @@ -1,10 +1,24 @@ from django.contrib import admin -from sports.models import League, SportEvent, Team +from sports.models import ( + League, + Player, + Round, + Season, + Sport, + SportEvent, + Team, +) from scrobbles.admin import ScrobbleInline +@admin.register(Sport) +class SportAdmin(admin.ModelAdmin): + date_hierarchy = "created" + ordering = ("name",) + + @admin.register(League) class LeagueAdmin(admin.ModelAdmin): date_hierarchy = "created" @@ -12,6 +26,27 @@ class LeagueAdmin(admin.ModelAdmin): ordering = ("name",) +@admin.register(Player) +class PlayerAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("name", "league", "team") + ordering = ("name",) + + +@admin.register(Season) +class SeasonAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("name", "league") + ordering = ("name",) + + +@admin.register(Round) +class RoundAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("name", "season") + ordering = ("name",) + + @admin.register(Team) class TeamAdmin(admin.ModelAdmin): date_hierarchy = "created" @@ -26,12 +61,17 @@ class SportEventAdmin(admin.ModelAdmin): "title", "event_type", "start", - "home_team", - "away_team", - "season", + "comp_str", + "round", ) - list_filter = ("season", "home_team", "away_team") + list_filter = ("round__season", "home_team", "away_team") ordering = ("-created",) inlines = [ ScrobbleInline, ] + + def comp_str(self, obj): + if obj.home_team: + return f'{obj.away_team} @ {obj.home_team}' + if obj.player_one: + return f'{obj.player_one} v {obj.player_two}' diff --git a/vrobbler/apps/sports/migrations/0003_sport_league_sport.py b/vrobbler/apps/sports/migrations/0003_sport_league_sport.py new file mode 100644 index 0000000..64173a5 --- /dev/null +++ b/vrobbler/apps/sports/migrations/0003_sport_league_sport.py @@ -0,0 +1,71 @@ +# Generated by Django 4.1.5 on 2023-01-21 04:21 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('sports', '0002_rename_start_utc_sportevent_start'), + ] + + operations = [ + migrations.CreateModel( + name='Sport', + 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' + ), + ), + ('name', models.CharField(max_length=255)), + ( + 'uuid', + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ('thesportsdb_id', models.IntegerField(blank=True, null=True)), + ( + 'default_event_run_time', + models.IntegerField(blank=True, null=True), + ), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.AddField( + model_name='league', + name='sport', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.sport', + ), + ), + ] diff --git a/vrobbler/apps/sports/migrations/0004_alter_league_options_alter_sport_options_and_more.py b/vrobbler/apps/sports/migrations/0004_alter_league_options_alter_sport_options_and_more.py new file mode 100644 index 0000000..932829d --- /dev/null +++ b/vrobbler/apps/sports/migrations/0004_alter_league_options_alter_sport_options_and_more.py @@ -0,0 +1,254 @@ +# Generated by Django 4.1.5 on 2023-01-22 20:20 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('sports', '0003_sport_league_sport'), + ] + + operations = [ + migrations.AlterModelOptions( + name='league', + options={}, + ), + migrations.AlterModelOptions( + name='sport', + options={}, + ), + migrations.AlterModelOptions( + name='team', + options={}, + ), + migrations.RemoveField( + model_name='sportevent', + name='league', + ), + migrations.AlterField( + model_name='league', + name='thesportsdb_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='sport', + name='thesportsdb_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='team', + name='thesportsdb_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name='Season', + 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' + ), + ), + ('name', models.CharField(max_length=255)), + ( + 'uuid', + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ( + 'thesportsdb_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'league', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.league', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Round', + 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' + ), + ), + ('name', models.CharField(max_length=255)), + ( + 'uuid', + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ( + 'thesportsdb_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'season', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.season', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Player', + 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' + ), + ), + ('name', models.CharField(max_length=255)), + ( + 'uuid', + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ( + 'thesportsdb_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'league', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.league', + ), + ), + ( + 'team', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.team', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='sportevent', + name='player_one', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='player_one_set', + to='sports.player', + ), + ), + migrations.AddField( + model_name='sportevent', + name='player_two', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='player_two_set', + to='sports.player', + ), + ), + migrations.AddField( + model_name='sportevent', + name='round', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.round', + ), + ), + migrations.AlterField( + model_name='sportevent', + name='season', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='sports.season', + ), + ), + ] diff --git a/vrobbler/apps/sports/migrations/0005_remove_sportevent_season.py b/vrobbler/apps/sports/migrations/0005_remove_sportevent_season.py new file mode 100644 index 0000000..3960195 --- /dev/null +++ b/vrobbler/apps/sports/migrations/0005_remove_sportevent_season.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.5 on 2023-01-22 20:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sports', '0004_alter_league_options_alter_sport_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='sportevent', + name='season', + ), + ] diff --git a/vrobbler/apps/sports/migrations/0006_alter_sportevent_event_type.py b/vrobbler/apps/sports/migrations/0006_alter_sportevent_event_type.py new file mode 100644 index 0000000..61704dd --- /dev/null +++ b/vrobbler/apps/sports/migrations/0006_alter_sportevent_event_type.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.5 on 2023-01-22 21:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sports', '0005_remove_sportevent_season'), + ] + + operations = [ + migrations.AlterField( + model_name='sportevent', + name='event_type', + field=models.CharField( + choices=[('UK', 'Unknown'), ('GA', 'Game'), ('MA', 'Match')], + default='UK', + max_length=2, + ), + ), + ] diff --git a/vrobbler/apps/sports/migrations/0007_sport_default_event_type.py b/vrobbler/apps/sports/migrations/0007_sport_default_event_type.py new file mode 100644 index 0000000..7b5b7e0 --- /dev/null +++ b/vrobbler/apps/sports/migrations/0007_sport_default_event_type.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.5 on 2023-01-22 22:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sports', '0006_alter_sportevent_event_type'), + ] + + operations = [ + migrations.AddField( + model_name='sport', + name='default_event_type', + field=models.CharField( + choices=[('UK', 'Unknown'), ('GA', 'Game'), ('MA', 'Match')], + default='UK', + max_length=2, + ), + ), + ] diff --git a/vrobbler/apps/sports/models.py b/vrobbler/apps/sports/models.py index e532864..f318252 100644 --- a/vrobbler/apps/sports/models.py +++ b/vrobbler/apps/sports/models.py @@ -7,52 +7,93 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel +from sqlalchemy import update from scrobbles.mixins import ScrobblableMixin +from vrobbler.apps.sports.utils import ( + get_players_from_event, + get_round_name_from_event, +) logger = logging.getLogger(__name__) BNULL = {"blank": True, "null": True} -class League(TimeStampedModel): +class SportEventType(models.TextChoices): + UNKNOWN = 'UK', _('Unknown') + GAME = 'GA', _('Game') + MATCH = 'MA', _('Match') + + +class TheSportsDbMixin(TimeStampedModel): name = models.CharField(max_length=255) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) - logo = models.ImageField(upload_to="sports/league-logos/", **BNULL) - abbreviation_str = models.CharField(max_length=10, **BNULL) - thesportsdb_id = models.IntegerField(**BNULL) + thesportsdb_id = models.CharField(max_length=255, **BNULL) + + class Meta: + abstract = True def __str__(self): return self.name + +class Sport(TheSportsDbMixin): + default_event_run_time = models.IntegerField(**BNULL) + default_event_type = models.CharField( + max_length=2, + choices=SportEventType.choices, + default=SportEventType.UNKNOWN, + ) + + # TODO Add these to the default run_time for Football + # run_time_seconds = 11700 + # run_time_ticks = run_time_seconds * 1000 + @property + def default_event_run_time_ticks(self): + return self.default_event_run_time * 1000 + + +class League(TheSportsDbMixin): + logo = models.ImageField(upload_to="sports/league-logos/", **BNULL) + abbreviation_str = models.CharField(max_length=10, **BNULL) + sport = models.ForeignKey(Sport, on_delete=models.DO_NOTHING, **BNULL) + @property def abbreviation(self): return self.abbreviation_str -class Team(TimeStampedModel): - name = models.CharField(max_length=255) - uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) +class Season(TheSportsDbMixin): league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL) - thesportsdb_id = models.IntegerField(**BNULL) def __str__(self): - return self.name + return f'{self.name} season of {self.league}' + + +class Team(TheSportsDbMixin): + league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL) + + +class Player(TheSportsDbMixin): + league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL) + team = models.ForeignKey(Team, on_delete=models.DO_NOTHING, **BNULL) + + +class Round(TheSportsDbMixin): + season = models.ForeignKey(Season, on_delete=models.DO_NOTHING, **BNULL) + + def __str__(self): + return f'{self.name} of {self.season}' class SportEvent(ScrobblableMixin): COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90) - class Type(models.TextChoices): - UNKNOWN = 'UK', _('Unknown') - GAME = 'GA', _('Game') - MATCH = 'MA', _('Match') - MEET = 'ME', _('Meet') - event_type = models.CharField( max_length=2, - choices=Type.choices, - default=Type.UNKNOWN, + choices=SportEventType.choices, + default=SportEventType.UNKNOWN, ) - league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL) + round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL) start = models.DateTimeField(**BNULL) home_team = models.ForeignKey( Team, @@ -66,10 +107,21 @@ class SportEvent(ScrobblableMixin): related_name='away_event_set', **BNULL, ) - season = models.CharField(max_length=255, **BNULL) + player_one = models.ForeignKey( + Player, + on_delete=models.DO_NOTHING, + related_name='player_one_set', + **BNULL, + ) + player_two = models.ForeignKey( + Player, + on_delete=models.DO_NOTHING, + related_name='player_two_set', + **BNULL, + ) def __str__(self): - return f"{self.start.date()} - {self.league.abbreviation} - {self.home_team} v {self.away_team}" + return f"{self.start.date()} - {self.round} - {self.home_team} v {self.away_team}" def get_absolute_url(self): return reverse("sports:event_detail", kwargs={'slug': self.uuid}) @@ -81,33 +133,88 @@ class SportEvent(ScrobblableMixin): exist. """ - league_dict = { - "abbreviation_str": data_dict.get("LeagueName", ""), - "thesportsdb_id": data_dict.get("LeagueId", ""), - } - league, _created = League.objects.get_or_create(**league_dict) + # Find or create our Sport + sid = data_dict.get("Sport") + sport, s_created = Sport.objects.get_or_create(thesportsdb_id=sid) + if s_created: + sport.name = sid + sport.save(update_fields=['name']) - home_team_dict = { - "name": data_dict.get("HomeTeamName", ""), - "thesportsdb_id": data_dict.get("HomeTeamId", ""), - "league": league, - } - home_team, _created = Team.objects.get_or_create(**home_team_dict) + # Find or create our League + lid = data_dict.get("LeagueId") + league, l_created = League.objects.get_or_create( + thesportsdb_id=lid, sport=sport + ) + if l_created: + league.sport = sport + league.name = data_dict.get("LeagueName", "") + league.save(update_fields=['sport', 'name']) - away_team_dict = { - "name": data_dict.get("AwayTeamName", ""), - "thesportsdb_id": data_dict.get("AwayTeamId", ""), - "league": league, - } - away_team, _created = Team.objects.get_or_create(**away_team_dict) + # Find or create our Season + seid = data_dict.get('Season') + season, se_created = Season.objects.get_or_create( + thesportsdb_id=seid, league=league + ) + if se_created: + season.name = seid + season.save(update_fields['name']) + + # Find or create our Round + rid = data_dict.get('RoundId') + round, r_created = Round.objects.get_or_create( + thesportsdb_id=rid, season=season + ) + if r_created: + round.season = season + round.save(update_fields=['season']) + + # Set some special data for Tennis + player_one = None + player_two = None + if data_dict.get('Sport') == 'Tennis': + event_name = data_dict.get('Name', '') + if not round.name: + round.name = get_round_name_from_event(event_name) + round.save(update_fields=['name']) + + players_list = get_players_from_event(event_name) + player_one = Player.objects.filter( + name__icontains=players_list[0] + ).first() + if not player_one: + player_one = Player.objects.create(name=players_list[0]) + player_two = Player.objects.filter( + name__icontains=players_list[1] + ).first() + if not player_two: + player_two = Player.objects.create(name=players_list[1]) + + home_team = None + away_team = None + if data_dict.get("HomeTeamName"): + home_team_dict = { + "name": data_dict.get("HomeTeamName", ""), + "thesportsdb_id": data_dict.get("HomeTeamId", ""), + "league": league, + } + home_team, _created = Team.objects.get_or_create(**home_team_dict) + + away_team_dict = { + "name": data_dict.get("AwayTeamName", ""), + "thesportsdb_id": data_dict.get("AwayTeamId", ""), + "league": league, + } + away_team, _created = Team.objects.get_or_create(**away_team_dict) event_dict = { "title": data_dict.get("Name"), - "event_type": data_dict.get("ItemType"), + "event_type": sport.default_event_type, "home_team": home_team, "away_team": away_team, + "player_one": player_one, + "player_two": player_two, "start": data_dict['Start'], - "league": league, + "round": round, "run_time_ticks": data_dict.get("RunTimeTicks"), "run_time": data_dict.get("RunTime", ""), } diff --git a/vrobbler/apps/sports/utils.py b/vrobbler/apps/sports/utils.py new file mode 100644 index 0000000..5df09c5 --- /dev/null +++ b/vrobbler/apps/sports/utils.py @@ -0,0 +1,11 @@ +def get_round_name_from_event(event: str) -> str: + return ' '.join(event.split(' ')[:2]) + + +def get_players_from_event(event: str) -> list[str]: + players = [] + event_name = get_round_name_from_event(event) + players_list = event.split(event_name)[1:][0].split('vs') + players.append(players_list[0].strip()) + players.append(players_list[1].strip()) + return players diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.html index 6631a59..e2375ca 100644 --- a/vrobbler/templates/base.html +++ b/vrobbler/templates/base.html @@ -206,7 +206,7 @@ {% if scrobble.track %}{{scrobble.track.artist}}
{% endif %} {% if scrobble.podcast_episode%}{{scrobble.podcast_episode.podcast}}
{% endif %} {% if scrobble.video.tv_series %}{{scrobble.video.tv_series }}
{% endif %} - {% if scrobble.sport_event %}{{scrobble.sport_event.league.abbreviation}}
{% endif %} + {% if scrobble.sport_event %}{{scrobble.sport_event.round.season.league}}
{% endif %} {{scrobble.timestamp|naturaltime}}
from {{scrobble.source}}