Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8236f43026 | |||
| ea1b43d1b8 |
81
PROJECT.org
81
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,14 +86,6 @@ 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:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
@ -505,6 +498,78 @@ 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.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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "46.0"
|
||||
version = "47.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
0
vrobbler/apps/sports/management/__init__.py
Normal file
0
vrobbler/apps/sports/management/__init__.py
Normal 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)
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/sports/migrations/0021_team_logo.py
Normal file
20
vrobbler/apps/sports/migrations/0021_team_logo.py
Normal 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/"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;">
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user