From ffcd8c40b48a6aed0606f9a2eaf98b3acac5e902 Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 31 Mar 2026 23:04:36 +0300 Subject: [PATCH] Add HowLongToBeat estimates to game cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch HLTB main/extra/completionist hours when a game item is saved - Re-fetch only when name or category changes on edit - Steam imports also fetch HLTB for each selected game - Cards show compact HLTB row: "HLTB: 40h · +extra 60h · 100% 100h" - Edit form shows HLTB breakdown as a hint next to Total hours field Co-Authored-By: Claude Sonnet 4.6 --- backlogger/hltb.py | 48 +++++++++++++++++++ .../migrations/0004_item_hltb_fields.py | 26 ++++++++++ backlogger/models.py | 5 ++ .../templates/backlogger/item_form.html | 8 ++++ backlogger/templates/backlogger/list.html | 5 ++ backlogger/views.py | 9 +++- requirements.txt | 1 + 7 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 backlogger/hltb.py create mode 100644 backlogger/migrations/0004_item_hltb_fields.py diff --git a/backlogger/hltb.py b/backlogger/hltb.py new file mode 100644 index 0000000..181eb03 --- /dev/null +++ b/backlogger/hltb.py @@ -0,0 +1,48 @@ +from howlongtobeatpy import HowLongToBeat + + +def _h(val): + """Return float hours if valid, else None.""" + try: + v = float(val) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def fetch(game_name): + """ + Search HowLongToBeat for game_name. + Returns dict with keys 'main', 'extra', 'complete' (each float or None), + or None if nothing found / on any error. + """ + try: + results = HowLongToBeat().search(game_name) + except Exception: + return None + + if not results: + return None + + best = max(results, key=lambda r: r.similarity) + if best.similarity < 0.4: + return None + + return { + 'main': _h(best.main_story), + 'extra': _h(best.main_extra), + 'complete': _h(best.completionist), + } + + +def apply_to_item(item): + """Fetch HLTB data and save it onto item. Silently does nothing on failure.""" + if item.category != 'games' or not item.name: + return + data = fetch(item.name) + if data is None: + return + item.hltb_main = data['main'] + item.hltb_extra = data['extra'] + item.hltb_complete = data['complete'] + item.save(update_fields=['hltb_main', 'hltb_extra', 'hltb_complete']) diff --git a/backlogger/migrations/0004_item_hltb_fields.py b/backlogger/migrations/0004_item_hltb_fields.py new file mode 100644 index 0000000..52576da --- /dev/null +++ b/backlogger/migrations/0004_item_hltb_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0003_assign_existing_items_to_first_user'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='hltb_main', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='hltb_extra', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='hltb_complete', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/backlogger/models.py b/backlogger/models.py index eef1e25..9bac648 100644 --- a/backlogger/models.py +++ b/backlogger/models.py @@ -35,6 +35,11 @@ class Item(models.Model): watched = models.BooleanField(null=True, blank=True) duration_minutes = models.IntegerField(null=True, blank=True) + # HowLongToBeat estimates (games only) + hltb_main = models.FloatField(null=True, blank=True) + hltb_extra = models.FloatField(null=True, blank=True) + hltb_complete = models.FloatField(null=True, blank=True) + class Meta: ordering = ['-favorite', 'name'] diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html index 5627b0f..ea5e449 100644 --- a/backlogger/templates/backlogger/item_form.html +++ b/backlogger/templates/backlogger/item_form.html @@ -188,6 +188,14 @@ {{ form.total_hours }} {{ form.total_hours.errors }} + {% if item.hltb_main or item.hltb_extra or item.hltb_complete %} +
+ HowLongToBeat: + {% if item.hltb_main %}Main {{ item.hltb_main|floatformat:0 }}h{% endif %} + {% if item.hltb_extra %} · +Extra {{ item.hltb_extra|floatformat:0 }}h{% endif %} + {% if item.hltb_complete %} · 100% {{ item.hltb_complete|floatformat:0 }}h{% endif %} +
+ {% endif %} diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html index 541dd46..332f99b 100644 --- a/backlogger/templates/backlogger/list.html +++ b/backlogger/templates/backlogger/list.html @@ -213,6 +213,11 @@ {% if item.hours_played is not None %} {{ item.hours_played|floatformat:1 }}h played{% if item.total_hours %} / {{ item.total_hours|floatformat:0 }}h total{% endif %} {% endif %} + {% if item.hltb_main or item.hltb_extra or item.hltb_complete %} +
+ HLTB:{% if item.hltb_main %} {{ item.hltb_main|floatformat:0 }}h{% endif %}{% if item.hltb_extra %} · +extra {{ item.hltb_extra|floatformat:0 }}h{% endif %}{% if item.hltb_complete %} · 100% {{ item.hltb_complete|floatformat:0 }}h{% endif %} +
+ {% endif %} {% elif item.category == 'books' %} {% if item.pages_read is not None %} {{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %} diff --git a/backlogger/views.py b/backlogger/views.py index 0926380..9805e0d 100644 --- a/backlogger/views.py +++ b/backlogger/views.py @@ -5,6 +5,7 @@ from django.urls import reverse from .models import Item from .forms import ItemForm, SignupForm from . import steam as steam_api +from . import hltb as hltb_api def signup(request): @@ -56,6 +57,7 @@ def item_add(request): item = form.save(commit=False) item.user = request.user item.save() + hltb_api.apply_to_item(item) return redirect('backlogger:list') else: form = ItemForm() @@ -68,7 +70,9 @@ def item_edit(request, pk): if request.method == 'POST': form = ItemForm(request.POST, instance=item) if form.is_valid(): - form.save() + updated = form.save() + if 'name' in form.changed_data or 'category' in form.changed_data: + hltb_api.apply_to_item(updated) return redirect('backlogger:list') else: form = ItemForm(instance=item) @@ -139,13 +143,14 @@ def steam_import(request): continue hours = game['hours'] progress = min(100.0, hours) if hours > 0 else 0.0 - Item.objects.create( + item = Item.objects.create( user=request.user, category=Item.GAMES, name=game['name'], hours_played=hours, progress_percent=progress, ) + hltb_api.apply_to_item(item) imported += 1 del request.session['steam_games'] diff --git a/requirements.txt b/requirements.txt index d4664a0..6af458c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ whitenoise>=6.6 requests>=2.31 beautifulsoup4>=4.12 deep-translator>=1.11 +howlongtobeatpy>=2.0