[boardgames] Adding email scrobbler for BG Stats

This commit is contained in:
2025-07-02 23:01:13 -04:00
parent 3548c29f97
commit 1590ce5f18
13 changed files with 469 additions and 49 deletions

View File

@ -1,6 +1,11 @@
from django.contrib import admin from django.contrib import admin
from boardgames.models import BoardGame, BoardGamePublisher from boardgames.models import (
BoardGame,
BoardGameLocation,
BoardGamePublisher,
BoardGameDesigner,
)
from scrobbles.admin import ScrobbleInline from scrobbles.admin import ScrobbleInline
@ -15,8 +20,29 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
ordering = ("-created",) ordering = ("-created",)
@admin.register(BoardGameDesigner)
class BoardGameDesignerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
)
ordering = ("-created",)
@admin.register(BoardGameLocation)
class BoardGameLocationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
"geo_location",
)
ordering = ("-created",)
@admin.register(BoardGame) @admin.register(BoardGame)
class GameAdmin(admin.ModelAdmin): class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"
list_display = ( list_display = (
"bggeek_id", "bggeek_id",

View File

@ -0,0 +1,167 @@
# Generated by Django 4.2.19 on 2025-07-03 01:57
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("locations", "0007_alter_geolocation_run_time_seconds"),
("boardgames", "0007_alter_boardgame_run_time_seconds"),
]
operations = [
migrations.CreateModel(
name="BoardGameDesigner",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("name", models.CharField(max_length=255)),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
("bgg_id", models.IntegerField(blank=True, null=True)),
("bio", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.RenameField(
model_name="boardgamepublisher",
old_name="igdb_id",
new_name="bgg_id",
),
migrations.AddField(
model_name="boardgame",
name="bgstats_id",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="cooperative",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="boardgame",
name="expansion_for_boardgame",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="boardgames.boardgame",
),
),
migrations.AddField(
model_name="boardgame",
name="highest_wins",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="boardgame",
name="max_play_time",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="min_play_time",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="no_points",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="boardgame",
name="uses_teams",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="BoardGameLocation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("name", models.CharField(max_length=255)),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
("bgstats_id", models.IntegerField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="locations.geolocation",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.AddField(
model_name="boardgame",
name="designers",
field=models.ManyToManyField(
related_name="board_games", to="boardgames.boardgamedesigner"
),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.19 on 2025-07-03 02:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0008_boardgamedesigner_and_more"),
]
operations = [
migrations.AlterField(
model_name="boardgame",
name="cooperative",
field=models.BooleanField(blank=True, default=False, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="highest_wins",
field=models.BooleanField(blank=True, default=True, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="no_points",
field=models.BooleanField(blank=True, default=False, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="uses_teams",
field=models.BooleanField(blank=True, default=False, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-07-03 02:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0009_alter_boardgame_cooperative_and_more"),
]
operations = [
migrations.AlterField(
model_name="boardgamelocation",
name="bgstats_id",
field=models.UUIDField(blank=True, null=True),
),
]

View File

@ -14,6 +14,7 @@ from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BoardGameLogData from scrobbles.dataclasses import BoardGameLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from locations.models import GeoLocation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -23,7 +24,7 @@ class BoardGamePublisher(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL) logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
igdb_id = models.IntegerField(**BNULL) bgg_id = models.IntegerField(**BNULL)
def __str__(self): def __str__(self):
return self.name return self.name
@ -34,6 +35,39 @@ class BoardGamePublisher(TimeStampedModel):
) )
class BoardGameDesigner(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgg_id = models.IntegerField(**BNULL)
bio = models.TextField(**BNULL)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:designer_detail", kwargs={"slug": self.uuid}
)
class BoardGameLocation(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:location_detail", kwargs={"slug": self.uuid}
)
class BoardGame(ScrobblableMixin): class BoardGame(ScrobblableMixin):
COMPLETION_PERCENT = getattr( COMPLETION_PERCENT = getattr(
settings, "BOARD_GAME_COMPLETION_PERCENT", 100 settings, "BOARD_GAME_COMPLETION_PERCENT", 100
@ -53,6 +87,10 @@ class BoardGame(ScrobblableMixin):
publisher = models.ForeignKey( publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
) )
designers = models.ManyToManyField(
BoardGameDesigner,
related_name="board_games",
)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
description = models.TextField(**BNULL) description = models.TextField(**BNULL)
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL) cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
@ -87,6 +125,16 @@ class BoardGame(ScrobblableMixin):
published_date = models.DateField(**BNULL) published_date = models.DateField(**BNULL)
recommended_age = models.PositiveSmallIntegerField(**BNULL) recommended_age = models.PositiveSmallIntegerField(**BNULL)
bggeek_id = models.CharField(max_length=255, **BNULL) bggeek_id = models.CharField(max_length=255, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
uses_teams = models.BooleanField(default=False, **BNULL)
cooperative = models.BooleanField(default=False, **BNULL)
highest_wins = models.BooleanField(default=True, **BNULL)
no_points = models.BooleanField(default=False, **BNULL)
min_play_time = models.IntegerField(**BNULL)
max_play_time = models.IntegerField(**BNULL)
expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self): def __str__(self):
return self.title return self.title

View File

@ -1,13 +1,11 @@
from decimal import Decimal, getcontext from decimal import Decimal, getcontext
import logging import logging
from typing import Dict from typing import Dict
from uuid import uuid4
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -5,6 +5,6 @@ from people.models import Person
@admin.register(Person) @admin.register(Person)
class PersonAdmin(admin.ModelAdmin): class PersonAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"
list_display = ("name", "bgg_username", "bgstat_id") list_display = ("name", "bgg_username", "bgstats_id")
ordering = ("-created",) ordering = ("-created",)
search_fields = ("name",) search_fields = ("name",)

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.19 on 2025-07-03 02:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("people", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="person",
name="bgstat_id",
),
migrations.AddField(
model_name="person",
name="bgstats_id",
field=models.UUIDField(blank=True, null=True),
),
]

View File

@ -11,7 +11,7 @@ class Person(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL) user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
name = models.CharField(max_length=100, **BNULL) name = models.CharField(max_length=100, **BNULL)
bgstat_id = models.CharField(max_length=100, **BNULL) bgstats_id = models.UUIDField(**BNULL)
bgg_username = models.CharField(max_length=100, **BNULL) bgg_username = models.CharField(max_length=100, **BNULL)
lichess_username = models.CharField(max_length=100, **BNULL) lichess_username = models.CharField(max_length=100, **BNULL)
bio = models.TextField(**BNULL) bio = models.TextField(**BNULL)

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.19 on 2025-07-03 02:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"profiles",
"0024_userprofile_bgstat_id_userprofile_imap_auto_import_and_more",
),
]
operations = [
migrations.RenameField(
model_name="userprofile",
old_name="bgstat_id",
new_name="bgstats_id",
),
]

View File

@ -31,7 +31,7 @@ class UserProfile(TimeStampedModel):
task_context_tags_str = models.CharField(max_length=255, **BNULL) task_context_tags_str = models.CharField(max_length=255, **BNULL)
bgstat_id = models.CharField(max_length=255, **BNULL) bgstats_id = models.CharField(max_length=255, **BNULL)
bgg_username = models.CharField(max_length=255, **BNULL) bgg_username = models.CharField(max_length=255, **BNULL)
lichess_username = models.CharField(max_length=255, **BNULL) lichess_username = models.CharField(max_length=255, **BNULL)

View File

@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
def process_scrobbles_from_imap() -> list[Scrobble] | None: def process_scrobbles_from_imap() -> list[Scrobble] | None:
"""For all user profiles with IMAP creds, check inbox for scrobbleable email attachments.""" """For all user profiles with IMAP creds, check inbox for scrobbleable email attachments."""
scrobbles_to_create: list[Scrobble] = [] scrobbles_created: list[Scrobble] = []
active_profiles = UserProfile.objects.filter(imap_auto_import=True) active_profiles = UserProfile.objects.filter(imap_auto_import=True)
for profile in active_profiles: for profile in active_profiles:
@ -23,11 +23,12 @@ def process_scrobbles_from_imap() -> list[Scrobble] | None:
) )
mail = imaplib.IMAP4_SSL(profile.imap_url) mail = imaplib.IMAP4_SSL(profile.imap_url)
mail.login(profile.imap_user, profile.imap_pass) mail.login(profile.imap_user, profile.imap_pass)
mail.select("INBOX") mail.select("INBOX") # TODO configure this in profile
# Search for unseen emails # Search for unseen emails
status, messages = mail.search(None, "(UNSEEN)") status, messages = mail.search(None, "UnSeen")
if status != "OK": if status != "OK":
logger.info("IMAP status not OK", extra={"status": status})
return return
for uid in messages[0].split(): for uid in messages[0].split():
@ -37,13 +38,15 @@ def process_scrobbles_from_imap() -> list[Scrobble] | None:
continue continue
try: try:
msg = email.message_from_bytes(msg_data[0][1]) message = email.message_from_bytes(msg_data[0][1])
logger.info("Processing email message", extra={"msg": msg}) logger.info(
"Processing email message", extra={"email_msg": message}
)
except IndexError: except IndexError:
logger.info("No email message data found") logger.info("No email message data found")
return return
for part in msg.walk(): for part in message.walk():
if part.get_content_disposition() == "attachment": if part.get_content_disposition() == "attachment":
filename = part.get_filename() filename = part.get_filename()
if filename: if filename:
@ -57,42 +60,34 @@ def process_scrobbles_from_imap() -> list[Scrobble] | None:
parsed_json = "" parsed_json = ""
# Try parsing JSON if applicable # Try parsing JSON if applicable
parsed_json = {}
if filename.lower().endswith(".bgsplay"): if filename.lower().endswith(".bgsplay"):
try: try:
parsed_json = json.loads( parsed_json = json.loads(
file_data.decode("utf-8") file_data.decode("utf-8")
) )
scrobbles_to_create.append(
email_scrobble_board_game(
parsed_json, profile.user_id
)
)
except Exception as e: except Exception as e:
# You might want to log this # You might want to log this
print( print(
f"Failed to parse JSON from {filename}: {e}" f"Failed to parse JSON from {filename}: {e}"
) )
# Avoid duplicates if not parsed_json:
if not EmailAttachment.objects.filter( logger.info("No JSON found in BG Stats file")
email_uid=uid.decode(), filename=filename continue
).exists():
attachment = EmailAttachment( scrobble = email_scrobble_board_game(
email_uid=uid.decode(), filename=filename parsed_json, profile.user_id
) )
attachment.file.save( if scrobble:
filename, ContentFile(file_data) scrobbles_created.append(scrobble)
)
attachment.save()
mail.logout() mail.logout()
if scrobbles_to_create: if scrobbles_created:
logger.info( logger.info(
f"Creating {len(scrobbles_to_create)} new scrobbles", f"Creating {len(scrobbles_created)} new scrobbles",
extra={"scrobbles_to_create": scrobbles_to_create}, extra={"scrobbles_created": scrobbles_created},
) )
created_scrobbles = Scrobble.objects.bulk_create(scrobbles_to_create) return scrobbles_created
return created_scrobbles
logger.info(f"No new scrobbles found in IMAP folders") logger.info(f"No new scrobbles found in IMAP folders")

View File

@ -1,12 +1,12 @@
import logging import logging
import re import re
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, Optional from typing import Any, Optional
import pendulum import pendulum
import pytz import pytz
from beers.models import Beer from beers.models import Beer
from boardgames.models import BoardGame from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from books.models import Book from books.models import Book
from bricksets.models import BrickSet from bricksets.models import BrickSet
from dateutil.parser import parse from dateutil.parser import parse
@ -15,6 +15,7 @@ from locations.constants import LOCATION_PROVIDERS
from locations.models import GeoLocation from locations.models import GeoLocation
from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
from music.models import Track from music.models import Track
from people.models import Person
from podcasts.models import PodcastEpisode from podcasts.models import PodcastEpisode
from podcasts.utils import parse_mopidy_uri from podcasts.utils import parse_mopidy_uri
from puzzles.models import Puzzle from puzzles.models import Puzzle
@ -28,10 +29,10 @@ from scrobbles.utils import convert_to_seconds, extract_domain
from sports.models import SportEvent from sports.models import SportEvent
from sports.thesportsdb import lookup_event_from_thesportsdb from sports.thesportsdb import lookup_event_from_thesportsdb
from tasks.models import Task from tasks.models import Task
from tasks.utils import get_title_from_labels
from videogames.howlongtobeat import lookup_game_from_hltb from videogames.howlongtobeat import lookup_game_from_hltb
from videogames.models import VideoGame from videogames.models import VideoGame
from videos.models import Video from videos.models import Video
from tasks.utils import get_title_from_labels
from webpages.models import WebPage from webpages.models import WebPage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -311,26 +312,117 @@ def manual_scrobble_board_game(
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict) return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
def find_and_enrich_board_game_data(game_dict: dict) -> BoardGame | None:
"""TODO Move this to a utility somewhere"""
game = BoardGame.find_or_create(game_dict.get("bggId"))
if game:
game.cooperative = game_dict.get("cooperative", False)
game.highest_wins = game_dict.get("highestWins", True)
game.no_points = game_dict.get("noPoints", False)
game.uses_teams = game_dict.get("useTeams", False)
if not game.rating:
game.rating = game_dict.get("rating") / 10
game.save()
if game_dict.get("designers"):
for designer_name in game_dict.get("designers", "").split(", "):
BoardGameDesigner.objects.get_or_create(name=designer_name)
return game
def email_scrobble_board_game( def email_scrobble_board_game(
bgstat_data: dict[str, Any], user_id: int bgstat_data: dict[str, Any], user_id: int
) -> Scrobble | None: ) -> Scrobble | None:
game_dict: dict[str, Any] = bgstat_data.get("games", [])[0] player_dict = {}
if not game_dict.get("bggId", False): for player in bgstat_data.get("players", []):
if player.get("isAnonymous"):
person, _created = Person.objects.get_or_create(name="Anonymous")
else:
person, _created = Person.objects.get_or_create(
bgstats_id=player.get("uuid")
)
if not person.name:
person.name = player.get("name", "")
person.save()
player_dict[player.get("id")] = person
game_list: list = bgstat_data.get("games", [])
if not game_list:
logger.info( logger.info(
"Data from BG Stats JSON had not BGG ID, not scrobbling", "No game data from BG Stats, not scrobbling",
extra={"bgstat_data": bgstat_data}, extra={"bgstat_data": bgstat_data},
) )
return return
boardgame = BoardGame.find_or_create(game_dict.get("bggId")) base_game = None
# TODO Enrich data from our bgstats data? expansions = []
# log_data = {}
# TODO Build up board game meta data from the rest of the data for game in game_list:
scrobble_dict = {} logger.info(f"Finding and enriching {game.get('name')}")
players_list: list[dict[str, Any]] = bgstat_data.get("players", []) enriched_game = find_and_enrich_board_game_data(game)
location: dict[str, Any] = bgstat_data.get("locations", [])[0] if game.get("isBaseGame"):
base_game = enriched_game
elif game.get("isExpansion"):
expansions.append(enriched_game)
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict) for expansion in expansions:
expansion.expansion_for_boardgame = base_game
expansion.save()
log_data["expansion_ids"] = [e.id for e in expansions]
location_dict: dict[str, Any] = bgstat_data.get("locations", [])[0]
location, _created = BoardGameLocation.objects.get_or_create(
bgstats_id=location_dict.get("uuid")
)
if not location.name:
location.name = location_dict.get("name")
geoloc = GeoLocation.objects.filter(
title__icontains=location.name
).first()
if geoloc:
location.geo_location = geoloc
location.save()
log_data["location_id"] = location.id
play_dict = bgstat_data.get("plays", [])[0]
if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False):
log_data["board"] = play_dict.get("board")
log_data["players"] = []
for score_dict in play_dict.get("playerScores", []):
log_data["players"].append(
{
"person_id": player_dict[score_dict.get("playerRefId")].id,
"new": score_dict.get("newPlayer"),
"win": score_dict.get("winner"),
"score": score_dict.get("score"),
"rank": score_dict.get("rank"),
"seat_order": score_dict.get("seatOrder"),
"role": score_dict.get("role"),
}
)
start = parse(play_dict.get("playDate"))
duration_seconds = play_dict.get("durationMin") * 60
stop = start + timedelta(seconds=duration_seconds)
scrobble_dict = {
"user_id": user_id,
"timestamp": start,
"playback_position_seconds": duration_seconds,
"source": "BG Stats",
"log": log_data,
}
scrobble = Scrobble.create_or_update(base_game, user_id, scrobble_dict)
scrobble.stop_timestamp = stop
scrobble.in_progress = False
scrobble.played_to_completion = True
scrobble.save()
return scrobble
def manual_scrobble_from_url( def manual_scrobble_from_url(