Add HowLongToBeat estimates to game cards
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- 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 <noreply@anthropic.com>
This commit is contained in:
48
backlogger/hltb.py
Normal file
48
backlogger/hltb.py
Normal file
@@ -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'])
|
||||||
26
backlogger/migrations/0004_item_hltb_fields.py
Normal file
26
backlogger/migrations/0004_item_hltb_fields.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -35,6 +35,11 @@ class Item(models.Model):
|
|||||||
watched = models.BooleanField(null=True, blank=True)
|
watched = models.BooleanField(null=True, blank=True)
|
||||||
duration_minutes = models.IntegerField(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:
|
class Meta:
|
||||||
ordering = ['-favorite', 'name']
|
ordering = ['-favorite', 'name']
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,14 @@
|
|||||||
<label>Total hours <span class="optional">optional</span></label>
|
<label>Total hours <span class="optional">optional</span></label>
|
||||||
{{ form.total_hours }}
|
{{ form.total_hours }}
|
||||||
{{ form.total_hours.errors }}
|
{{ form.total_hours.errors }}
|
||||||
|
{% if item.hltb_main or item.hltb_extra or item.hltb_complete %}
|
||||||
|
<div style="margin-top:0.4rem;font-size:0.72rem;color:#475569;line-height:1.6">
|
||||||
|
HowLongToBeat:
|
||||||
|
{% if item.hltb_main %}<span style="color:#64748b">Main {{ item.hltb_main|floatformat:0 }}h</span>{% endif %}
|
||||||
|
{% if item.hltb_extra %}<span style="color:#64748b"> · +Extra {{ item.hltb_extra|floatformat:0 }}h</span>{% endif %}
|
||||||
|
{% if item.hltb_complete %}<span style="color:#64748b"> · 100% {{ item.hltb_complete|floatformat:0 }}h</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -213,6 +213,11 @@
|
|||||||
{% if item.hours_played is not None %}
|
{% 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 %}
|
{{ item.hours_played|floatformat:1 }}h played{% if item.total_hours %} / {{ item.total_hours|floatformat:0 }}h total{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if item.hltb_main or item.hltb_extra or item.hltb_complete %}
|
||||||
|
<div style="margin-top:0.3rem;font-size:0.72rem;color:#475569">
|
||||||
|
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 %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% elif item.category == 'books' %}
|
{% elif item.category == 'books' %}
|
||||||
{% if item.pages_read is not None %}
|
{% if item.pages_read is not None %}
|
||||||
{{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %}
|
{{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.urls import reverse
|
|||||||
from .models import Item
|
from .models import Item
|
||||||
from .forms import ItemForm, SignupForm
|
from .forms import ItemForm, SignupForm
|
||||||
from . import steam as steam_api
|
from . import steam as steam_api
|
||||||
|
from . import hltb as hltb_api
|
||||||
|
|
||||||
|
|
||||||
def signup(request):
|
def signup(request):
|
||||||
@@ -56,6 +57,7 @@ def item_add(request):
|
|||||||
item = form.save(commit=False)
|
item = form.save(commit=False)
|
||||||
item.user = request.user
|
item.user = request.user
|
||||||
item.save()
|
item.save()
|
||||||
|
hltb_api.apply_to_item(item)
|
||||||
return redirect('backlogger:list')
|
return redirect('backlogger:list')
|
||||||
else:
|
else:
|
||||||
form = ItemForm()
|
form = ItemForm()
|
||||||
@@ -68,7 +70,9 @@ def item_edit(request, pk):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = ItemForm(request.POST, instance=item)
|
form = ItemForm(request.POST, instance=item)
|
||||||
if form.is_valid():
|
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')
|
return redirect('backlogger:list')
|
||||||
else:
|
else:
|
||||||
form = ItemForm(instance=item)
|
form = ItemForm(instance=item)
|
||||||
@@ -139,13 +143,14 @@ def steam_import(request):
|
|||||||
continue
|
continue
|
||||||
hours = game['hours']
|
hours = game['hours']
|
||||||
progress = min(100.0, hours) if hours > 0 else 0.0
|
progress = min(100.0, hours) if hours > 0 else 0.0
|
||||||
Item.objects.create(
|
item = Item.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
category=Item.GAMES,
|
category=Item.GAMES,
|
||||||
name=game['name'],
|
name=game['name'],
|
||||||
hours_played=hours,
|
hours_played=hours,
|
||||||
progress_percent=progress,
|
progress_percent=progress,
|
||||||
)
|
)
|
||||||
|
hltb_api.apply_to_item(item)
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
del request.session['steam_games']
|
del request.session['steam_games']
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ whitenoise>=6.6
|
|||||||
requests>=2.31
|
requests>=2.31
|
||||||
beautifulsoup4>=4.12
|
beautifulsoup4>=4.12
|
||||||
deep-translator>=1.11
|
deep-translator>=1.11
|
||||||
|
howlongtobeatpy>=2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user