Compare commits

...

4 Commits
46.0 ... 47.1

Author SHA1 Message Date
b541e1084d [release] Bump to version 47.1
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 32s
- Untangle the sports migrations errors
2026-06-06 23:47:25 -04:00
c9b9da4abc [sports] Fix migrations 2026-06-06 23:47:10 -04:00
8236f43026 [release] Bump to version 47.0
Some checks failed
build / test (push) Successful in 2m4s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Failing after 46s
- Change sports scrobbling a bit
2026-06-06 23:35:30 -04:00
ea1b43d1b8 [sports] Big sports structure revamp
Some checks failed
build / test (push) Has been cancelled
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.
2026-06-06 23:32:21 -04:00
19 changed files with 581 additions and 112 deletions

View File

@ -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 [0/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,84 @@ 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.
** TODO [#B] Add OMDB source as backup when TMDB returns nothing :videos:metadata:imdb:
:PROPERTIES:
:ID: 20195445-7fdd-49be-9767-103b12da0bfb
:END:
*** Description
TMDb works great for most cases. There are some edge cases, though where it does
not import videos, when TV shows are split up differently in TMDb than in IMDB.
One example I stumbled on is the 2020 reboot of Animaniacs. TMDb splits the
epiodes up in three parts, while they were always broadcast three-in-one, and
that's how IMDB lists them. Thus, the IMDB ID means nothing, and the videos end
up unenriched.
* Version 47.1 [1/1]
** DONE [#A] Untangle the sports migrations errors :sports:bug:migrations:
:PROPERTIES:
:ID: 4d50ca2e-f45b-dde8-e3c9-cd84f353b349
:END:
* Version 47.0 [1/1]
** 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:

View File

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

View File

@ -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]] = None

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,111 @@
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
),
]

View File

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

View File

@ -0,0 +1,27 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("sports", "0021_team_logo"),
]
operations = [
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",
),
]

View File

@ -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)
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,
)
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
def __str__(self):
return (
f"{self.start.date()} - {self.round} - {self.home_team} v {self.away_team}"
)
@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)
round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)
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"])
round_obj.season = season
round_obj.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])
# 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)
away_team, at_created = Team.objects.get_or_create(**away_team_dict)
if at_created:
from sports.thesportsdb import enrich_team_logo
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)
enrich_team_logo(away_team)
return event
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, logdata

View File

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

View File

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

View File

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

View File

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

View File

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