Board Game Category Feature

This commit is contained in:
zacharyskemp
2025-11-18 10:51:21 -05:00
parent a3e4e40849
commit de57ebb237
10 changed files with 534 additions and 2 deletions

View File

@@ -6,7 +6,7 @@
![Codecov](https://codecov.io/github/FuzzyGrim/Yamtrack/branch/dev/graph/badge.svg?token=PWUG660120)
![GitHub](https://img.shields.io/badge/license-AGPL--3.0-blue)
Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games and books.
Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games, books, comics, and board games.
## 🚀 Demo
@@ -14,7 +14,7 @@ You can try the app at [yamtrack.fuzzygrim.com](https://yamtrack.fuzzygrim.com)
## ✨ Features
- 🎬 Track movies, tv shows, anime, manga, games, books and comics.
- 🎬 Track movies, tv shows, anime, manga, games, books, comics, and board games.
- 📺 Track each season of a tv show individually and episodes watched.
- ⭐ Save score, status, progress, repeats (rewatches, rereads...), start and end dates, or write a note.
- 📈 Keep a tracking history with each action with a media, such as when you added it, when you started it, when you started watching it again, etc.
@@ -109,6 +109,7 @@ MAL_API=API_KEY
IGDB_ID=IGDB_ID
IGDB_SECRET=IGDB_SECRET
STEAM_API_KEY=STEAM_API_SECRET
BGG_API_TOKEN=BGG_API_TOKEN
SECRET=SECRET
DEBUG=True
```

View File

@@ -155,6 +155,23 @@ MEDIA_TYPE_CONFIG = {
5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z"/>""",
"unit": ("#", "Issue"),
},
MediaTypes.BOARDGAME.value: {
"sources": [Sources.BGG],
"default_source": Sources.BGG.label,
"sample_query": "Catan",
"unicode_icon": "🎲",
"verb": ("play", "played"),
"text_color": "text-lime-400",
"stats_color": "#84cc16",
"svg_icon": """
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="8" cy="8" r="2"/>
<path d="M16 8h-2"/>
<circle cx="16" cy="16" r="2"/>
<path d="M8 16v-2"/>""",
"unit": ("#", "Play"),
"date_key": "publish_date",
},
}
# --- Status Configuration ---

View File

@@ -5,6 +5,7 @@ from app import config
from app.models import (
TV,
Anime,
BoardGame,
Book,
Comic,
Episode,
@@ -311,6 +312,21 @@ class ComicForm(MediaForm):
}
class BoardgameForm(MediaForm):
"""Form for board games."""
class Meta(MediaForm.Meta):
"""Bind form to model."""
model = BoardGame
labels = {
"progress": (
f"Progress "
f"({media_type_config.get_unit(MediaTypes.BOARDGAME.value, short=False)}s)"
),
}
class TvForm(MediaForm):
"""Form for TV shows."""

View File

@@ -0,0 +1,102 @@
# Generated by Django 5.2.7 on 2025-11-11 14:51
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0051_migrate_simkl_periodoc_tasks'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BoardGame',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
('progress', models.PositiveIntegerField(default=0)),
('progressed_at', model_utils.fields.MonitorField(default=django.utils.timezone.now, monitor='progress')),
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
('start_date', models.DateTimeField(blank=True, null=True)),
('end_date', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
],
options={
'ordering': ['user', 'item', '-created_at'],
'abstract': False,
},
),
migrations.CreateModel(
name='HistoricalBoardGame',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
('progress', models.PositiveIntegerField(default=0)),
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
('start_date', models.DateTimeField(blank=True, null=True)),
('end_date', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
],
options={
'verbose_name': 'historical board game',
'verbose_name_plural': 'historical board games',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.RemoveConstraint(
model_name='item',
name='app_item_media_type_valid',
),
migrations.RemoveConstraint(
model_name='item',
name='app_item_source_valid',
),
migrations.AlterField(
model_name='item',
name='media_type',
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic'), ('boardgame', 'Board Game')], default='movie', max_length=10),
),
migrations.AlterField(
model_name='item',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('manual', 'Manual')], max_length=20),
),
migrations.AddConstraint(
model_name='item',
constraint=models.CheckConstraint(condition=models.Q(('source__in', ['tmdb', 'mal', 'mangaupdates', 'igdb', 'openlibrary', 'hardcover', 'comicvine', 'bgg', 'manual'])), name='app_item_source_valid'),
),
migrations.AddConstraint(
model_name='item',
constraint=models.CheckConstraint(condition=models.Q(('media_type__in', ['tv', 'season', 'episode', 'movie', 'anime', 'manga', 'game', 'book', 'comic', 'boardgame'])), name='app_item_media_type_valid'),
),
migrations.AddField(
model_name='boardgame',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.item'),
),
migrations.AddField(
model_name='boardgame',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='historicalboardgame',
name='history_user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -45,6 +45,7 @@ class Sources(models.TextChoices):
OPENLIBRARY = "openlibrary", "Open Library"
HARDCOVER = "hardcover", "Hardcover"
COMICVINE = "comicvine", "Comic Vine"
BGG = "bgg", "BoardGameGeek"
MANUAL = "manual", "Manual"
@@ -60,6 +61,7 @@ class MediaTypes(models.TextChoices):
GAME = "game", "Game"
BOOK = "book", "Book"
COMIC = "comic", "Comic"
BOARDGAME = "boardgame", "Boardgame"
class Item(CalendarTriggerMixin, models.Model):
@@ -1614,3 +1616,9 @@ class Comic(Media):
"""Model for comics."""
tracker = FieldTracker()
class BoardGame(Media):
"""Model for board games."""
tracker = FieldTracker()

300
src/app/providers/bgg.py Normal file
View File

@@ -0,0 +1,300 @@
"""BoardGameGeek (BGG) API provider for board game metadata.
IMPORTANT: As of November 2024, BGG requires API registration and authorization tokens.
To use this provider:
1. Register for API access at: https://boardgamegeek.com/using_the_xml_api
2. Obtain your Bearer token from BGG
3. Set BGG_API_TOKEN environment variable with your token
API Documentation: https://boardgamegeek.com/wiki/page/BGG_XML_API2
Registration & Authorization: https://boardgamegeek.com/using_the_xml_api
API Terms: https://boardgamegeek.com/wiki/page/XML_API_Terms_of_Use
Rate Limiting: BGG recommends waiting 5 seconds between requests to avoid 500/503 errors.
"""
import logging
import time
import xml.etree.ElementTree as ET
import requests
from django.conf import settings
from django.core.cache import cache
from app.models import MediaTypes, Sources
from app.providers import services
from app import helpers
logger = logging.getLogger(__name__)
base_url = "https://boardgamegeek.com/xmlapi2"
# Rate limiting: Disabled - BGG handles rate limiting on their end
# If you encounter 429/500/503 errors, enable by setting MIN_REQUEST_INTERVAL > 0
MIN_REQUEST_INTERVAL = 0 # seconds
_last_request_time = 0
def _rate_limit():
"""Ensure minimum time between BGG API requests."""
global _last_request_time
current_time = time.time()
time_since_last = current_time - _last_request_time
if time_since_last < MIN_REQUEST_INTERVAL:
sleep_time = MIN_REQUEST_INTERVAL - time_since_last
time.sleep(sleep_time)
_last_request_time = time.time()
def _bgg_request(endpoint, params=None):
"""Make a rate-limited request to BGG API.
Args:
endpoint: API endpoint (e.g., 'search', 'thing')
params: Query parameters dict
Returns:
ElementTree root element
Raises:
ProviderAPIError: On API errors
"""
_rate_limit()
url = f"{base_url}/{endpoint}"
headers = {
"User-Agent": "Yamtrack/1.0 (https://github.com/FuzzyGrim/Yamtrack)"
}
# BGG now requires Bearer token authorization (as of Nov 2024)
bgg_token = getattr(settings, "BGG_API_TOKEN", None)
if bgg_token:
headers["Authorization"] = f"Bearer {bgg_token}"
try:
response = requests.get(url, params=params, headers=headers, timeout=10)
# Check for missing/invalid authorization
if response.status_code == 401:
logger.error(
"BGG API requires authorization. Register at "
"https://boardgamegeek.com/using_the_xml_api "
"and set BGG_API_TOKEN in your environment."
)
response.raise_for_status()
# BGG may return 202 (queued request), need to retry
if response.status_code == 202:
logger.info("BGG queued request, retrying...")
time.sleep(2)
return _bgg_request(endpoint, params)
# Parse XML response
root = ET.fromstring(response.text)
return root
except requests.exceptions.HTTPError as error:
raise services.ProviderAPIError(Sources.BGG.value, error)
except ET.ParseError as error:
logger.exception("Failed to parse BGG XML response")
raise services.ProviderAPIError(
Sources.BGG.value,
error,
"Invalid XML response from BGG",
)
def search(query, page=1):
"""Search for board games on BoardGameGeek.
Args:
query: Search term
page: Page number for client-side pagination
Returns:
Formatted search response with results
"""
# Cache all game IDs separately from page-specific results
ids_cache_key = f"bgg_search_ids_{query.lower()}"
game_data = cache.get(ids_cache_key)
if not game_data:
# First search or cache expired - fetch from BGG
params = {
"query": query,
"type": "boardgame",
}
root = _bgg_request("search", params)
# Collect game IDs and names from search results
game_ids = []
game_names = {}
for item in root.findall(".//item"):
game_id = item.get("id")
name_elem = item.find("name")
if name_elem is not None and game_id:
game_ids.append(game_id)
game_names[game_id] = name_elem.get("value", "Unknown")
game_data = {"ids": game_ids, "names": game_names}
# Cache game IDs for 1 day
cache.set(ids_cache_key, game_data, 60 * 60 * 24)
game_ids = game_data["ids"]
game_names = game_data["names"]
# Check if this specific page is cached
page_cache_key = f"bgg_search_page_{query.lower()}_p{page}"
cached_page = cache.get(page_cache_key)
if cached_page:
return cached_page
# Paginate: 20 results per page
per_page = 20
total_results = len(game_ids)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
page_ids = game_ids[start_idx:end_idx]
# Fetch thumbnails only for current page
results = []
if page_ids:
try:
thing_params = {
"id": ",".join(page_ids),
# Don't filter by type - allows BGG to return expansions/accessories too
}
thing_root = _bgg_request("thing", thing_params)
# Build a map of game_id -> image
thumbnails = {}
for item in thing_root.findall(".//item"):
game_id = item.get("id")
# Try thumbnail first, fall back to full image if no thumbnail
thumbnail_elem = item.find("thumbnail")
if thumbnail_elem is not None and thumbnail_elem.text:
thumbnails[game_id] = thumbnail_elem.text
else:
image_elem = item.find("image")
if image_elem is not None and image_elem.text:
thumbnails[game_id] = image_elem.text
# Build results with images
for game_id in page_ids:
results.append({
"media_id": game_id,
"source": Sources.BGG.value,
"media_type": MediaTypes.BOARDGAME.value,
"title": game_names[game_id],
"image": thumbnails.get(game_id, settings.IMG_NONE),
})
except Exception as e:
logger.warning(f"Failed to fetch thumbnails: {e}")
# Fallback to results without images
for game_id in page_ids:
results.append({
"media_id": game_id,
"source": Sources.BGG.value,
"media_type": MediaTypes.BOARDGAME.value,
"title": game_names[game_id],
"image": settings.IMG_NONE,
})
data = helpers.format_search_response(
page=page,
per_page=per_page,
total_results=total_results,
results=results,
)
# Cache this page for 1 day
cache.set(page_cache_key, data, 60 * 60 * 24)
return data
def metadata(media_id):
"""Get detailed metadata for a board game.
Args:
media_id: BGG thing ID
Returns:
Dict with game details
"""
cache_key = f"bgg_metadata_{media_id}"
cached = cache.get(cache_key)
if cached:
return cached
params = {
"id": media_id,
"stats": "1",
}
root = _bgg_request("thing", params)
item = root.find(".//item")
if item is None:
raise services.ProviderAPIError(
Sources.BGG.value,
None,
f"Game not found: {media_id}",
)
# Extract primary name
name_elem = item.find(".//name[@type='primary']")
title = name_elem.get("value", "Unknown") if name_elem is not None else "Unknown"
# Extract image
image_elem = item.find("image")
image = image_elem.text if image_elem is not None else settings.IMG_NONE
# Extract description
desc_elem = item.find("description")
description = desc_elem.text if desc_elem is not None else ""
# Extract year
year_elem = item.find("yearpublished")
year = year_elem.get("value", "") if year_elem is not None else ""
# Extract player counts
minplayers_elem = item.find("minplayers")
maxplayers_elem = item.find("maxplayers")
minplayers = minplayers_elem.get("value", "") if minplayers_elem is not None else ""
maxplayers = maxplayers_elem.get("value", "") if maxplayers_elem is not None else ""
# Extract playtime
playtime_elem = item.find("playingtime")
playtime = playtime_elem.get("value", "") if playtime_elem is not None else ""
# Extract minimum age
minage_elem = item.find("minage")
minage = minage_elem.get("value", "") if minage_elem is not None else ""
# Extract BGG rating
avg_rating_elem = item.find(".//statistics/ratings/average")
avg_rating = avg_rating_elem.get("value", "") if avg_rating_elem is not None else ""
result = {
"media_id": media_id,
"source": Sources.BGG.value,
"media_type": MediaTypes.BOARDGAME.value,
"title": title,
"image": image,
"description": description,
"year": year,
"players": f"{minplayers}-{maxplayers}" if minplayers and maxplayers else "",
"playtime": f"{playtime} min" if playtime else "",
"age": f"{minage}+" if minage else "",
"bgg_rating": avg_rating,
"max_progress": None, # Board games don't have max progress - tracks plays instead
"related": {},
}
# Cache for 7 days
cache.set(cache_key, result, 60 * 60 * 24 * 7)
return result

View File

@@ -10,6 +10,7 @@ from requests_ratelimiter import LimiterAdapter, LimiterSession
from app.models import MediaTypes, Sources
from app.providers import (
bgg,
comicvine,
hardcover,
igdb,
@@ -207,6 +208,7 @@ def get_media_metadata(
if source == Sources.HARDCOVER.value
else openlibrary.book(media_id),
MediaTypes.COMIC.value: lambda: comicvine.comic(media_id),
MediaTypes.BOARDGAME.value: lambda: bgg.metadata(media_id),
}
return metadata_retrievers[media_type]()
@@ -231,5 +233,7 @@ def search(media_type, query, page, source=None):
response = hardcover.search(query, page)
elif media_type == MediaTypes.COMIC.value:
response = comicvine.search(query, page)
elif media_type == MediaTypes.BOARDGAME.value:
response = bgg.search(query, page)
return response

View File

@@ -374,6 +374,15 @@ IGDB_SECRET = config(
)
IGDB_NSFW = config("IGDB_NSFW", default=False, cast=bool)
# BoardGameGeek API Token - Register at https://boardgamegeek.com/using_the_xml_api
BGG_API_TOKEN = config(
"BGG_API_TOKEN",
default=secret(
"BGG_API_TOKEN_FILE",
"92f43ab1-d1d5-4e18-8b82-d1f56dc12927",
),
)
STEAM_API_KEY = config(
"STEAM_API_KEY",
default=secret(

View File

@@ -0,0 +1,57 @@
# Generated by Django 5.2.7 on 2025-11-11 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0052_add_boardgame'),
('auth', '0012_alter_user_first_name_max_length'),
('users', '0037_remove_user_home_sort_valid_alter_user_home_sort'),
]
operations = [
migrations.RemoveConstraint(
model_name='user',
name='last_search_type_valid',
),
migrations.AddField(
model_name='user',
name='boardgame_enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='user',
name='boardgame_layout',
field=models.CharField(choices=[('grid', 'Grid'), ('table', 'Table')], default='grid', max_length=20),
),
migrations.AddField(
model_name='user',
name='boardgame_sort',
field=models.CharField(choices=[('score', 'Rating'), ('title', 'Title'), ('progress', 'Progress'), ('start_date', 'Start Date'), ('end_date', 'End Date')], default='score', max_length=20),
),
migrations.AddField(
model_name='user',
name='boardgame_status',
field=models.CharField(choices=[('All', 'All'), ('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='All', max_length=20),
),
migrations.AlterField(
model_name='user',
name='home_sort',
field=models.CharField(choices=[('upcoming', 'Upcoming'), ('recent', 'Recent'), ('completion', 'Completion'), ('episodes_left', 'Episodes Left'), ('title', 'Title')], default='upcoming', max_length=20),
),
migrations.AlterField(
model_name='user',
name='last_search_type',
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic'), ('boardgame', 'Board Game')], default='tv', max_length=10),
),
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(condition=models.Q(('last_search_type__in', ['tv', 'movie', 'anime', 'manga', 'game', 'book', 'comic', 'boardgame'])), name='last_search_type_valid'),
),
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(condition=models.Q(('home_sort__in', ['upcoming', 'recent', 'completion', 'episodes_left', 'title'])), name='home_sort_valid'),
),
]

View File

@@ -267,6 +267,24 @@ class User(AbstractUser):
choices=MediaStatusChoices.choices,
)
# Media type preferences: Board Games
boardgame_enabled = models.BooleanField(default=True)
boardgame_layout = models.CharField(
max_length=20,
default=LayoutChoices.GRID,
choices=LayoutChoices.choices,
)
boardgame_sort = models.CharField(
max_length=20,
default=MediaSortChoices.SCORE,
choices=MediaSortChoices.choices,
)
boardgame_status = models.CharField(
max_length=20,
default=MediaStatusChoices.ALL,
choices=MediaStatusChoices.choices,
)
# UI preferences
clickable_media_cards = models.BooleanField(
default=False,