sessions / ea1b43d1b81c280c279b55e39c6c0ac16585ddd3

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit ea1b43d1b81c280c279b55e39c6c0ac16585ddd3Author: Colin Powell <colin@unbl.ink>Date:   Sat Jun 6 23:32:21 2026 -0400    [sports] Big sports structure revamp        This should make scrobbling sports more like tasks.        The root scrobbled items are a little more generic, but    it's easier to see viewing patterns.diff --git a/PROJECT.org b/PROJECT.orgindex 6ecc5b9..d21f1ff 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -2,6 +2,7 @@  We should convert this PROJECT file to put tickets in a subdirectory, tickets, with each ticket having it's own shortid_title.org  * Overview+ Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of the shows and movies I was watching. More specifically, I broke my ankle a few days after Christmas in 2022 and spent the next four months very slowly@@ -85,15 +86,7 @@ fetching and simple saving. **** Bookmarklet *** Metadata sources **** Scraper-* Chores-** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:-:PROPERTIES:-:ID:       514e9285-96f1-265f-56df-118c12f60918-:END:-:LOGBOOK:-CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] =>  0:20-:END:-* Backlog [0/12] :vrobbler:project:personal:+* Backlog [1/13] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: :PROPERTIES: :ID:       702462cf-d54b-48c6-8a7c-78b8de751deb@@ -505,6 +498,62 @@ needed import celery task. This is how the WebDAV celery task currently works. This would also be an opporunity to clean up the code around WebDAV imports and make them more re-usable for other import services. +** DONE [#B] Change sports scrobbling a bit :feature:sports:scrobbles:+:PROPERTIES:+:ID:       cd27d683-c847-4251-b3d1-8243f45c01ca+:END:+ +*** Description++Currently, the way we scrobble sports means that basically the same event will+never be scrobbled again. I will likely never watch the 2025 Monaco Grand Prix+again, but I will watch the Monaco Grand Prix again. But I also wont watch one+specific game between Arsenal and Man City twice, but I may watch those two+teams play multiple times.++What if instead of scrobbling a specific sports event on a specific date, we+make the unique Scrobblable item the players or teams in the event?++That would not work for races where the unique item would have to be the name of+the event.++Maybe that means SportEvent is too generic, and we'd need the event type to be+scrobble items.++A race, the Indy 500 or Coke 600, or Boston Marathon would be scrobblable, while+for games, the teams would be unique, so a game between Arsenal and Man City+would be unique (with extra logdata context for who's home and who's away, and+the location, could even have the weather per scrobble).++And finally, for Tennis, the title would be the round of the event, Roland+Garros Women's Semifinal, US Open Men's Final, Miami Invitational Round of 32,+with two players, or two teams and a start datetime, which is similar to what we+have now. The round becomes not a foreign key, but just a string, and we'd need+a FK to an organizer field which would replace league, and would be like "ATP+Tour" or "PGA Tour". Season would also need to be a string, and would be+something like: 2026 or 2024-2025.++Examples:+  - Super Bowl+  - Sochaux v Concarneau+  - French Open Final+  - Carlos Alcaraz v Jannik Sinner++We'd also want a script to reorganize existing sports events and move scrobbles+to the right place as best as we're able, and to flag sportsevents and scrobbles+that could not automatically be migrated with a unique tag like+"migration-failed"++Ultimately I think what we need is to greatly simplify the SportEvent to be just a placeholder+for a sport event type for a given league, then each scrobble holds the details of teams, players+start, thesportsdb_id, round and season.++Thus, I've already simplified that model, but what we need is a migration script that will move+existing complex SportEvent instances into very basic ones, and updating any scrobbles for those+events with a new SportEventLogData structure with all the specific details in it. We also need to+move the obj.round.season.league into the FK for the given event.++ * Version 46.0 [1/1] ** DONE [#C] Add sentiment parsing for Scrobbles with notes :scrobbles:sentiment: :PROPERTIES:diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.pyindex 1e0abdf..cbcf585 100644--- a/vrobbler/apps/scrobbles/dataclasses.py+++ b/vrobbler/apps/scrobbles/dataclasses.py@@ -112,9 +112,7 @@ class BaseLogData(JSONDataclass):                     continue          if dt is None:-            m = re.match(-                r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned-            )+            m = re.match(r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned)             if m:                 try:                     dt = datetime.strptime(@@ -143,9 +141,25 @@ class BaseLogData(JSONDataclass):          md = markdown.Markdown(extensions=["extra"])         allowed_tags = [-            "p", "br", "strong", "em", "a", "ul", "ol", "li",-            "code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",-            "hr", "img",+            "p",+            "br",+            "strong",+            "em",+            "a",+            "ul",+            "ol",+            "li",+            "code",+            "pre",+            "blockquote",+            "h1",+            "h2",+            "h3",+            "h4",+            "h5",+            "h6",+            "hr",+            "img",         ]          note_items = []@@ -174,7 +188,9 @@ class BaseLogData(JSONDataclass):              ts_html = ""             if ts:-                ts_html = f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'+                ts_html = (+                    f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'+                )              content_html = bleach.clean(                 md.convert(text),@@ -194,6 +210,14 @@ class LongPlayLogData(JSONDataclass):     long_play_complete: bool = False  +@dataclass+class SportEventLogData(BaseLogData):+    thesportsdb_id: Optional[str] = None+    start: Optional[str] = None+    round_name: Optional[str] = None+    season_name: Optional[str] = None++ @dataclass class WithPeopleLogData(JSONDataclass):     with_people_ids: Optional[list[int]] = Nonediff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 35ea184..cea70dd 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -884,8 +884,7 @@ class Scrobble(TimeStampedModel):          # Strip log-only keys (stored in JSONField but not part of LogData dataclass)         logdata_kwargs = {-            k: v for k, v in log_dict.items()-            if k in logdata_cls.__dataclass_fields__+            k: v for k, v in log_dict.items() if k in logdata_cls().__dataclass_fields__         }          try:diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.pyindex 5c57c50..c9e0bf5 100644--- a/vrobbler/apps/scrobbles/scrobblers.py+++ b/vrobbler/apps/scrobbles/scrobblers.py@@ -242,12 +242,13 @@ def manual_scrobble_event( ):     data_dict = lookup_event_from_thesportsdb(thesportsdb_id) -    event = SportEvent.find_or_create(data_dict)+    event, logdata = SportEvent.find_or_create(data_dict)     scrobble_dict = {         "user_id": user_id,         "timestamp": timezone.now(),         "playback_position_seconds": 0,         "source": "TheSportsDB",+        "log": logdata,     }     return Scrobble.create_or_update(event, user_id, scrobble_dict) diff --git a/vrobbler/apps/sports/admin.py b/vrobbler/apps/sports/admin.pyindex 7c9e086..f6731f9 100644--- a/vrobbler/apps/sports/admin.py+++ b/vrobbler/apps/sports/admin.py@@ -59,19 +59,27 @@ class SportEventAdmin(admin.ModelAdmin):     date_hierarchy = "created"     list_display = (         "title",+        "league",         "event_type",-        "start",         "comp_str",-        "round",     )-    list_filter = ("round__season", "home_team", "away_team")+    list_filter = ("league", "event_type")     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}"+        teams = list(obj.teams.all())+        if len(teams) >= 2:+            return f"{teams[1]} v {teams[0]}"++        players = list(obj.players.all())+        if len(players) >= 2:+            return f"{players[0]} v {players[1]}"++        if len(players) == 1:+            return str(players[0])++        if len(teams) == 1:+            return str(teams[0])diff --git a/vrobbler/apps/sports/management/__init__.py b/vrobbler/apps/sports/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/sports/management/commands/__init__.py b/vrobbler/apps/sports/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/sports/management/commands/enrich_sports_logos.py b/vrobbler/apps/sports/management/commands/enrich_sports_logos.pynew file mode 100644index 0000000..aeec209--- /dev/null+++ b/vrobbler/apps/sports/management/commands/enrich_sports_logos.py@@ -0,0 +1,36 @@+from django.core.management.base import BaseCommand++from sports.models import League, Team+from sports.thesportsdb import enrich_league_logo, enrich_team_logo, has_logo+++class Command(BaseCommand):+    help = "Fetch missing league and team logos from TheSportsDB"++    def add_arguments(self, parser):+        parser.add_argument(+            "--dry-run",+            action="store_true",+            help="Show what would be done without making changes",+        )++    def handle(self, *args, **options):+        dry_run = options["dry_run"]++        leagues = League.objects.filter(thesportsdb_id__isnull=False)+        for league in leagues:+            if has_logo(league):+                continue+            if dry_run:+                self.stdout.write(f"Would enrich logo for league: {league.name} ({league.thesportsdb_id})")+            else:+                enrich_league_logo(league)++        teams = Team.objects.filter(thesportsdb_id__isnull=False)+        for team in teams:+            if has_logo(team):+                continue+            if dry_run:+                self.stdout.write(f"Would enrich logo for team: {team.name} ({team.thesportsdb_id})")+            else:+                enrich_team_logo(team)diff --git a/vrobbler/apps/sports/migrations/0019_sportevent_league_alter_sportevent_away_team_and_more.py b/vrobbler/apps/sports/migrations/0019_sportevent_league_alter_sportevent_away_team_and_more.pynew file mode 100644index 0000000..455b460--- /dev/null+++ b/vrobbler/apps/sports/migrations/0019_sportevent_league_alter_sportevent_away_team_and_more.py@@ -0,0 +1,24 @@+# Generated by Django 4.2.30 on 2026-06-06 15:29++from django.db import migrations, models+import django.db.models.deletion+++class Migration(migrations.Migration):++    dependencies = [+        ("sports", "0018_alter_sportevent_genre"),+    ]++    operations = [+        migrations.AddField(+            model_name="sportevent",+            name="league",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="sports.league",+            ),+        ),+    ]diff --git a/vrobbler/apps/sports/migrations/0020_migrate_sport_event_data_to_logdata.py b/vrobbler/apps/sports/migrations/0020_migrate_sport_event_data_to_logdata.pynew file mode 100644index 0000000..6ffbaf9--- /dev/null+++ b/vrobbler/apps/sports/migrations/0020_migrate_sport_event_data_to_logdata.py@@ -0,0 +1,127 @@+import json++from django.db import migrations, models+import django.db.models.deletion+++def canonical_key(event):+    if event.home_team_id and event.away_team_id:+        return ("teams", event.league_id, event.home_team_id, event.away_team_id)+    if event.player_one_id and event.player_two_id:+        return ("players", event.league_id, event.player_one_id, event.player_two_id)+    return ("title", event.league_id, event.event_type, (event.title or "").strip())+++def build_logdata(event):+    logdata = {}+    if event.thesportsdb_id:+        logdata["thesportsdb_id"] = event.thesportsdb_id+    if event.start:+        logdata["start"] = (+            event.start.isoformat()+            if hasattr(event.start, "isoformat")+            else str(event.start)+        )+    if event.round:+        logdata["round_name"] = event.round.name or str(event.round)+        if event.round.season:+            logdata["season_name"] = event.round.season.name or str(event.round.season)+    return logdata+++def merge_scrobble_logs(scrobble, logdata):+    existing_log = scrobble.log or {}+    if isinstance(existing_log, str):+        existing_log = json.loads(existing_log)+    existing_log.update(logdata)+    scrobble.log = existing_log+    scrobble.save(update_fields=["log"])+++def populate_league(event):+    if event.league:+        return+    if event.round and event.round.season and event.round.season.league:+        event.league = event.round.season.league+        event.save(update_fields=["league"])+++def populate_m2m(event):+    if event.home_team_id:+        event.teams.add(event.home_team_id)+    if event.away_team_id:+        event.teams.add(event.away_team_id)+    if event.player_one_id:+        event.players.add(event.player_one_id)+    if event.player_two_id:+        event.players.add(event.player_two_id)+++def migrate_sport_event_data(apps, schema_editor):+    SportEvent = apps.get_model("sports", "SportEvent")+    Scrobble = apps.get_model("scrobbles", "Scrobble")+    db_alias = schema_editor.connection.alias++    canonical_events = {}++    for event in SportEvent.objects.using(db_alias).iterator():+        populate_league(event)+        key = canonical_key(event)++        canonical = canonical_events.get(key)+        if not canonical:+            canonical_events[key] = event+            populate_m2m(event)+            logdata = build_logdata(event)+            for scrobble in (+                Scrobble.objects.using(db_alias).filter(sport_event=event).iterator()+            ):+                merge_scrobble_logs(scrobble, logdata)+        else:+            logdata = build_logdata(event)+            for scrobble in (+                Scrobble.objects.using(db_alias).filter(sport_event=event).iterator()+            ):+                scrobble.sport_event = canonical+                merge_scrobble_logs(scrobble, logdata)+                scrobble.save(update_fields=["sport_event", "log"])+            event.delete()+++class Migration(migrations.Migration):++    dependencies = [+        ("sports", "0019_sportevent_league_alter_sportevent_away_team_and_more"),+    ]++    operations = [+        migrations.AddField(+            model_name="sportevent",+            name="teams",+            field=models.ManyToManyField(blank=True, to="sports.team"),+        ),+        migrations.AddField(+            model_name="sportevent",+            name="players",+            field=models.ManyToManyField(blank=True, to="sports.player"),+        ),+        migrations.RunPython(+            migrate_sport_event_data, reverse_code=migrations.RunPython.noop+        ),+        migrations.RemoveField(+            model_name="sportevent",+            name="home_team",+        ),+        migrations.RemoveField(+            model_name="sportevent",+            name="away_team",+        ),+        migrations.RemoveField(+            model_name="sportevent",+            name="player_one",+        ),+        migrations.RemoveField(+            model_name="sportevent",+            name="player_two",+        ),+    ]diff --git a/vrobbler/apps/sports/migrations/0021_team_logo.py b/vrobbler/apps/sports/migrations/0021_team_logo.pynew file mode 100644index 0000000..0346865--- /dev/null+++ b/vrobbler/apps/sports/migrations/0021_team_logo.py@@ -0,0 +1,20 @@+# Generated by Django 4.2.29 on 2026-06-07 03:24++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("sports", "0020_migrate_sport_event_data_to_logdata"),+    ]++    operations = [+        migrations.AddField(+            model_name="team",+            name="logo",+            field=models.ImageField(+                blank=True, null=True, upload_to="sports/team-logos/"+            ),+        ),+    ]diff --git a/vrobbler/apps/sports/models.py b/vrobbler/apps/sports/models.pyindex 46879ef..0b625c7 100644--- a/vrobbler/apps/sports/models.py+++ b/vrobbler/apps/sports/models.py@@ -68,6 +68,7 @@ class Season(TheSportsDbMixin):  class Team(TheSportsDbMixin):     league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)+    logo = models.ImageField(upload_to="sports/team-logos/", **BNULL)   class Player(TheSportsDbMixin):@@ -88,50 +89,61 @@ class Round(TheSportsDbMixin): class SportEvent(ScrobblableMixin):     COMPLETION_PERCENT = getattr(settings, "SPORT_COMPLETION_PERCENT", 90) -    thesportsdb_id = models.CharField(max_length=255, **BNULL)     event_type = models.CharField(         max_length=2,         choices=SportEventType.choices,         default=SportEventType.UNKNOWN,     )-    round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)+    league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)++    @property+    def logdata_cls(self):+        from scrobbles.dataclasses import SportEventLogData++        return SportEventLogData++    teams = models.ManyToManyField(Team, blank=True)+    players = models.ManyToManyField(Player, blank=True)++    # Deprecated - data migrated to scrobble.log via SportEventLogData+    thesportsdb_id = models.CharField(max_length=255, **BNULL)     start = models.DateTimeField(**BNULL)-    home_team = models.ForeignKey(-        Team,-        on_delete=models.DO_NOTHING,-        related_name="home_event_set",-        **BNULL,-    )-    away_team = models.ForeignKey(-        Team,-        on_delete=models.DO_NOTHING,-        related_name="away_event_set",-        **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,-    )+    round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL) -    def __str__(self):-        return (-            f"{self.start.date()} - {self.round} - {self.home_team} v {self.away_team}"-        )+    def save(self, *args, **kwargs):+        old_instance = None+        try:+            old_instance = UserProfile.objects.get(pk=self.pk)+        except:+            pass++        if not self.title or (old_instance and old_instance.title != self.title):+            self.title = self.comp_str++        super(SportEvent, self).save(*args, **kwargs)++    def __str__(self) -> str:+        league = self.league+        if self.league and self.league.abbreviation_str:+            league = self.league.abbreviation_str+        return f"{self.title} - {league}"      def get_absolute_url(self):         return reverse("sports:event_detail", kwargs={"slug": self.uuid})      @property-    def subtitle(self):-        return self.round.season.league+    def subtitle(self) -> str:+        return self.comp_str++    @property+    def comp_str(self) -> str:+        if self.players.exists():+            return " v ".join(str(p) for p in self.players.all())++        if self.teams.exists():+            return " v ".join(str(t) for t in self.teams.all())++        return ""      @property     def strings(self) -> ScrobblableConstants:@@ -147,16 +159,17 @@ class SportEvent(ScrobblableMixin):      @property     def primary_image_url(self) -> str:-        url = ""-        if self.round.season.league.logo:-            url = self.round.season.league.logo.url-        return url+        if self.league and self.league.logo:+            return self.league.logo.url+        return ""      @classmethod-    def find_or_create(cls, data_dict: Dict) -> "Event":-        """Given a data dict from Jellyfin, does the heavy lifting of looking up-        the video and, if need, TV Series, creating both if they don't yet-        exist.+    def find_or_create(cls, data_dict: Dict) -> tuple["Event", dict]:+        """Given a data dict from TheSportsDB, finds or creates a canonical+        SportEvent by teams, players or title, and returns (event, logdata).++        The logdata dict contains per-scrobble details (thesportsdb_id, start,+        round/season names) that should be stored in the scrobble's log field.          """         # Find or create our Sport@@ -187,32 +200,29 @@ class SportEvent(ScrobblableMixin):          # Find or create our Round         rid = data_dict.get("RoundId")-        round, r_created = Round.objects.get_or_create(+        round_obj, r_created = Round.objects.get_or_create(             thesportsdb_id=rid,             season=season,             name=rid,         )         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])-+            round_obj.season = season+            round_obj.save(update_fields=["season"])++        # Build logdata with per-scrobble details+        logdata = {}+        logdata["thesportsdb_id"] = data_dict.get("EventId")+        start = data_dict.get("Start")+        if start:+            logdata["start"] = (+                start.isoformat() if hasattr(start, "isoformat") else str(start)+            )+        if round_obj:+            logdata["round_name"] = round_obj.name or str(round_obj)+            if round_obj.season:+                logdata["season_name"] = round_obj.season.name or str(round_obj.season)++        # Look up or create teams/players         home_team = None         away_team = None         if data_dict.get("HomeTeamName"):@@ -221,27 +231,73 @@ class SportEvent(ScrobblableMixin):                 "thesportsdb_id": data_dict.get("HomeTeamId", ""),                 "league": league,             }-            home_team, _created = Team.objects.get_or_create(**home_team_dict)+            home_team, ht_created = Team.objects.get_or_create(**home_team_dict)+            if ht_created:+                from sports.thesportsdb import enrich_team_logo++                enrich_team_logo(home_team)              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 = {-            "thesportsdb_id": data_dict.get("EventId"),-            "title": data_dict.get("Name"),-            "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.get("Start"),-            "round": round,-            "base_run_time_seconds": data_dict.get("RunTime"),-        }-        event, _created = cls.objects.get_or_create(**event_dict)--        return event+            away_team, at_created = Team.objects.get_or_create(**away_team_dict)+            if at_created:+                from sports.thesportsdb import enrich_team_logo++                enrich_team_logo(away_team)++        players_list = []+        if data_dict.get("Sport") == "Tennis":+            event_name = data_dict.get("Name", "")+            if not round_obj.name:+                round_obj.name = get_round_name_from_event(event_name)+                round_obj.save(update_fields=["name"])++            players_list = get_players_from_event(event_name)++        # Find existing canonical event by teams, players, or title+        event = None+        if home_team and away_team:+            event = (+                cls.objects.filter(league=league, teams=home_team)+                .filter(teams=away_team)+                .first()+            )+        if not event and players_list:+            player_objs = []+            for player_name in players_list:+                player = Player.objects.filter(name__icontains=player_name).first()+                if not player:+                    player = Player.objects.create(name=player_name)+                player_objs.append(player)+            qs = cls.objects.filter(league=league, players=player_objs[0])+            for player in player_objs[1:]:+                qs = qs.filter(players=player)+            event = qs.first()++        if not event:+            title = data_dict.get("Name", "").strip()+            if title:+                event = cls.objects.filter(league=league, title=title).first()++        if not event:+            event = cls.objects.create(+                title=data_dict.get("Name"),+                event_type=sport.default_event_type,+                league=league,+                base_run_time_seconds=data_dict.get("RunTime"),+            )++        # Ensure M2M is populated on the canonical event+        if home_team and not event.teams.filter(id=home_team.id).exists():+            event.teams.add(home_team)+        if away_team and not event.teams.filter(id=away_team.id).exists():+            event.teams.add(away_team)+        for player_name in players_list:+            player = Player.objects.filter(name__icontains=player_name).first()+            if player and not event.players.filter(id=player.id).exists():+                event.players.add(player)++        return event, logdatadiff --git a/vrobbler/apps/sports/thesportsdb.py b/vrobbler/apps/sports/thesportsdb.pyindex 2b894d0..c05cf5b 100644--- a/vrobbler/apps/sports/thesportsdb.py+++ b/vrobbler/apps/sports/thesportsdb.py@@ -1,10 +1,12 @@ import logging +import requests from dateutil.parser import parse from django.conf import settings+from django.core.files.base import ContentFile from django.utils import timezone from pysportsdb import TheSportsDbClient-from sports.models import Sport+from sports.models import League, Sport, Team  logger = logging.getLogger(__name__) @@ -12,6 +14,84 @@ API_KEY = getattr(settings, "THESPORTSDB_API_KEY", "2") client = TheSportsDbClient(api_key=API_KEY)  +def has_logo(league_or_team) -> bool:+    """Check if a model instance has a logo (handles both NULL and empty string)."""+    return bool(league_or_team.logo and league_or_team.logo.name)+++def enrich_league_logo(league: League) -> None:+    """Fetch the league badge from TheSportsDB and save it as the league logo."""+    if not league.thesportsdb_id or has_logo(league):+        return++    url = (+        f"https://www.thesportsdb.com/api/v1/json/{API_KEY}"+        f"/lookupleague.php?id={league.thesportsdb_id}"+    )+    try:+        resp = requests.get(url, timeout=10)+        data = resp.json()+        leagues = data.get("leagues", [])+        if not leagues:+            return+        badge_url = leagues[0].get("strBadge")+        if badge_url:+            r = requests.get(badge_url, timeout=10)+            if r.status_code == 200:+                fname = f"{league.uuid or league.thesportsdb_id}.png"+                league.logo.save(fname, ContentFile(r.content), save=True)+                logger.info(+                    "Saved league logo from TheSportsDB",+                    extra={"league_id": league.id, "league_name": league.name},+                )+    except Exception as e:+        logger.warning(+            "Failed to fetch league logo from TheSportsDB",+            extra={"league_id": league.id, "error": str(e)},+        )+++def enrich_team_logo(team: Team) -> None:+    """Fetch the team badge from TheSportsDB and save it as the team logo."""+    if not team.thesportsdb_id or has_logo(team):+        return++    try:+        badge_url = None++        # Try direct lookup by thesportsdb_id first (more reliable)+        url = (+            f"https://www.thesportsdb.com/api/v1/json/{API_KEY}"+            f"/lookupteam.php?id={team.thesportsdb_id}"+        )+        resp = requests.get(url, timeout=10)+        data = resp.json()+        api_teams = data.get("teams", [])+        if api_teams:+            badge_url = api_teams[0].get("strBadge")+        else:+            # Fall back to name search+            result = client.search_teams(team.name) or {}+            api_teams = result.get("teams", [])+            if api_teams:+                badge_url = api_teams[0].get("strBadge")++        if badge_url:+            r = requests.get(badge_url, timeout=10)+            if r.status_code == 200:+                fname = f"{team.uuid or team.thesportsdb_id}.png"+                team.logo.save(fname, ContentFile(r.content), save=True)+                logger.info(+                    "Saved team logo from TheSportsDB",+                    extra={"team_id": team.id, "team_name": team.name},+                )+    except Exception as e:+        logger.warning(+            "Failed to fetch team logo from TheSportsDB",+            extra={"team_id": team.id, "error": str(e)},+        )++ def lookup_event_from_thesportsdb(event_id: str) -> dict:      try:@@ -23,6 +103,18 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:         return {}     sport, _created = Sport.objects.get_or_create(thesportsdb_id=event.get("strSport")) +    # Find or create the league and optionally enrich its logo+    lid = event.get("idLeague")+    league, l_created = League.objects.get_or_create(+        thesportsdb_id=lid,+        defaults={"name": event.get("strLeague", "")},+    )+    if l_created:+        league.name = event.get("strLeague", "")+        league.sport = sport+        league.save(update_fields=["name", "sport"])+        enrich_league_logo(league)+     try:         start = parse(event.get("strTimestamp"))     except:@@ -38,7 +130,7 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:         "RunTime": sport.default_event_run_time_seconds,         "Sport": event.get("strSport"),         "Season": event.get("strSeason"),-        "LeagueId": event.get("idLeague"),+        "LeagueId": lid,         "LeagueName": event.get("strLeague"),         "HomeTeamId": event.get("idHomeTeam"),         "HomeTeamName": event.get("strHomeTeam"),diff --git a/vrobbler/apps/sports/views.py b/vrobbler/apps/sports/views.pyindex 5b156ce..9692fb7 100644--- a/vrobbler/apps/sports/views.py+++ b/vrobbler/apps/sports/views.py@@ -1,12 +1,12 @@-from django.views import generic from sports.models import SportEvent+from vrobbler.apps.scrobbles.views import ScrobbleableDetailView, ScrobbleableListView  -class SportEventListView(generic.ListView):+class SportEventListView(ScrobbleableListView):     model = SportEvent     paginate_by = 50  -class SportEventDetailView(generic.DetailView):+class SportEventDetailView(ScrobbleableDetailView):     model = SportEvent     slug_field = "uuid"diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.htmlindex 46628ad..0d7a622 100644--- a/vrobbler/templates/base.html+++ b/vrobbler/templates/base.html@@ -315,8 +315,7 @@                             {% for scrobble in now_playing_list %}                             <div class="now-playing">                                 {% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}-                                <p><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></p>-                                {% if scrobble.media_obj.subtitle %}<p><em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em></p>{% endif %}+                                <p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>                                 {% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}                                 <p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>                                 <div class="progress-bar" style="margin-right:5px;">diff --git a/vrobbler/templates/scrobbles/status.html b/vrobbler/templates/scrobbles/status.htmlindex bbb4d6d..e69e9f8 100644--- a/vrobbler/templates/scrobbles/status.html+++ b/vrobbler/templates/scrobbles/status.html@@ -110,7 +110,7 @@             {% if sporting %}             <div class="titles">                 <p><a href="{{sporting.media_obj.get_absolute_url}}">{{sporting.media_obj}}</a></p>-                {% if sporting.media_obj.subtitle %}<p><em><a href="{{sporting.media_obj.subtitle.get_absolute_url}}">{{sporting.media_obj.subtitle}}</a></em></p>{% endif %}+                {% if sporting.media_obj.subtitle %}<p><em><a href="{{sporting.get_absolute_url}}">{{sporting.media_obj.subtitle}}</a></em></p>{% endif %}             </div>             <p><small>{{sporting.timestamp|naturaltime}} from {{sporting.source}}</small></p>             {% else %}diff --git a/vrobbler/templates/sports/sportevent_detail.html b/vrobbler/templates/sports/sportevent_detail.htmlindex 164e267..a63ed0c 100644--- a/vrobbler/templates/sports/sportevent_detail.html+++ b/vrobbler/templates/sports/sportevent_detail.html@@ -1,8 +1,9 @@ {% extends "base_detail.html" %} -{% block title %}{{object.title}} - {{object.round.season.league}}{% endblock %}+{% block title %}{{object}}{% endblock %}  {% block details %}+<h2>{{object.subtitle}}</h2>  <div class="row">     <h2>{{object.tv_series}}</h2>