Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 | |||
| 52adcf83c7 | |||
| 0061623f7e |
62
PROJECT.org
62
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/20] :vrobbler:project:personal:
|
||||
* Backlog [0/22] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -579,6 +579,18 @@ named constants for maintainability.
|
||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
||||
|
||||
|
||||
** TODO [#A] Deduplicate BGG plays before posting :boardgames:bgg:duplication:
|
||||
:PROPERTIES:
|
||||
:ID: e9b842bf-0049-42e7-a060-f3ebd0067d2f
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
No check for existing BGG plays before posting, which can create duplicates.
|
||||
Should look up past plays by =bggeek_id= first.
|
||||
|
||||
File: ~vrobbler/apps/boardgames/bgg.py~ (line 117)
|
||||
|
||||
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
||||
:PROPERTIES:
|
||||
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
||||
@ -591,6 +603,54 @@ a helper method to create board game scrobbles given a json blob. It's
|
||||
independent of the email flow it was originally creatdd for
|
||||
|
||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||
|
||||
* Version 55.3 [3/3]
|
||||
** DONE [#C] =alt_names= feature for artists (commented out / dead code) :music:dead-code:
|
||||
:PROPERTIES:
|
||||
:ID: e22060a2-5f7a-4f33-9056-309ecd27159c
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
File: ~vrobbler/apps/music/models.py~ (line 236)
|
||||
|
||||
An entire block of code for tracking alternate artist names is commented
|
||||
out. The TODO questions whether it even works. Review: either implement
|
||||
properly or remove the dead code.
|
||||
|
||||
** DONE [#A] Put chart rebuilds in a lower priority task queue :charts:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: 43c90de0-fc1c-1139-dac7-9b7c82006b2e
|
||||
:END:
|
||||
** DONE [#A] Check for existing book scrobble and update page count :books:scrobbling:
|
||||
:PROPERTIES:
|
||||
:ID: 1a0609bc-6b16-4da4-96c1-59588229e4b4
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
File: ~vrobbler/apps/scrobbles/scrobblers.py~ (line 330)
|
||||
|
||||
When scrobbling a book (comic), the code doesn't check for prior scrobbles to
|
||||
update reading progress. Needed for proper page-count tracking.
|
||||
|
||||
|
||||
* Version 55.2 [2/2]
|
||||
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
|
||||
:PROPERTIES:
|
||||
:ID: 8cb34852-b18f-e794-cd9b-fb1ecad70a0d
|
||||
:END:
|
||||
** DONE [#A] Video game cleanup script should clear out broken images :metadata:videogames:
|
||||
:PROPERTIES:
|
||||
:ID: ca1f1ea9-0f79-082c-5ff7-867671faff4b
|
||||
:END:
|
||||
|
||||
* Version 55.1 [1/1]
|
||||
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
|
||||
:PROPERTIES:
|
||||
:ID: fbc421b5-21a3-4aed-9062-c59192ead065
|
||||
:END:
|
||||
|
||||
* Version 55.0 [3/3]
|
||||
** DONE [#B] Use pk ID for scrobble detail view, not uuid :scrobbles:
|
||||
:PROPERTIES:
|
||||
|
||||
2
Procfile
2
Procfile
@ -1,2 +1,2 @@
|
||||
web: python manage.py runserver 0.0.0.0:8014
|
||||
worker: celery -A vrobbler worker -l DEBUG
|
||||
worker: celery -A vrobbler worker -Q default,charts -l DEBUG
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "55.0"
|
||||
version = "55.3"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -236,19 +236,6 @@ class Artist(TimeStampedModel):
|
||||
)
|
||||
artist.fix_metadata()
|
||||
|
||||
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
|
||||
# If we did find our artist, but the found name is slightly differnt, record that
|
||||
# if artist and alt_name:
|
||||
# if not artist.alt_names:
|
||||
# artist.alt_names = alt_name
|
||||
# else:
|
||||
# artist.alt_names += f"\\{alt_name}"
|
||||
# logger.info(
|
||||
# f"Add alt_name {alt_name} to artist {artist}",
|
||||
# extra={"alt_name": alt_name, "artist_id": artist.id},
|
||||
# )
|
||||
# artist.save(update_fields=["alt_names"])
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
|
||||
@ -330,8 +330,6 @@ def manual_scrobble_book(
|
||||
|
||||
source = READCOMICSONLINE_URL.replace("https://", "")
|
||||
|
||||
# TODO: Check for scrobble of this book already and if so, update the page count
|
||||
|
||||
book = Book.find_or_create(title, url=url, enrich=True)
|
||||
|
||||
scrobble_dict = {
|
||||
|
||||
@ -1717,6 +1717,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
||||
for scrobble in day_map[day_num]:
|
||||
day_scrobbles.append(
|
||||
{
|
||||
"id": scrobble.pk,
|
||||
"uuid": scrobble.uuid,
|
||||
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
||||
"title": (
|
||||
|
||||
@ -10,26 +10,27 @@ def hrs_to_secs(hrs: float) -> int:
|
||||
return int(hrs * 60 * 60)
|
||||
|
||||
|
||||
def lookup_game_from_hltb(name_or_id: str) -> Optional[dict]:
|
||||
def lookup_game_from_hltb(name_or_id: str, search_by_title: bool = False) -> Optional[dict]:
|
||||
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
|
||||
the data in a dictonary mapped to our internal game fields
|
||||
|
||||
"""
|
||||
hltb_game = {}
|
||||
|
||||
try:
|
||||
hltb_id = int(name_or_id)
|
||||
except ValueError:
|
||||
hltb_id = None
|
||||
if not search_by_title:
|
||||
try:
|
||||
hltb_id = int(name_or_id)
|
||||
except ValueError:
|
||||
hltb_id = None
|
||||
|
||||
if hltb_id:
|
||||
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
||||
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
||||
if hltb_id:
|
||||
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
||||
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
||||
|
||||
if not hltb_game:
|
||||
results = HowLongToBeat().search(name_or_id)
|
||||
if not results:
|
||||
logger.warn(f"Lookup of game on HLtB failed for ID {name_or_id}")
|
||||
logger.warn(f"Lookup of game on HLtB failed via search {name_or_id!r}")
|
||||
return
|
||||
|
||||
hltb_game = results[0]
|
||||
|
||||
@ -19,6 +19,7 @@ GAMES_URL = "https://api.igdb.com/v4/games"
|
||||
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
|
||||
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
|
||||
COVER_URL = "https://api.igdb.com/v4/covers"
|
||||
PLATFORMS_URL = "https://api.igdb.com/v4/platforms"
|
||||
|
||||
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
|
||||
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
|
||||
@ -35,6 +36,20 @@ def get_igdb_token() -> str:
|
||||
return results.get("access_token")
|
||||
|
||||
|
||||
def lookup_platform_names(platform_ids: list, headers: dict) -> list:
|
||||
"""Resolve IGDB platform IDs to platform names"""
|
||||
if not platform_ids:
|
||||
return []
|
||||
ids_str = ",".join(str(pid) for pid in platform_ids)
|
||||
body = f"fields name; where id = ({ids_str});"
|
||||
resp = requests.post(PLATFORMS_URL, data=body, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.warn(f"Failed to resolve platform IDs {platform_ids}")
|
||||
return []
|
||||
results = json.loads(resp.content)
|
||||
return [p["name"] for p in results if "name" in p]
|
||||
|
||||
|
||||
def lookup_game_id_from_gdb(name: str) -> str:
|
||||
|
||||
headers = {
|
||||
@ -62,9 +77,10 @@ def lookup_game_id_from_gdb(name: str) -> str:
|
||||
"details": results.get("details"),
|
||||
},
|
||||
)
|
||||
# Sort our result by IDs so we always get the lowest ID, which is likely to be the least esoteric game
|
||||
results = sorted(results, key=lambda k: k.get("game", 250000))
|
||||
return results[0].get("game", "")
|
||||
# Sort results by release date (oldest first) to prefer the original game
|
||||
results = [r for r in results if r.get("game")]
|
||||
results = sorted(results, key=lambda k: k.get("published_at") or 9999999999)
|
||||
return results[0].get("game", "") if results else ""
|
||||
|
||||
|
||||
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||
@ -118,6 +134,16 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||
for genre in game.get("genres"):
|
||||
genres.append(genre["name"])
|
||||
|
||||
platforms = []
|
||||
if "release_dates" in game.keys():
|
||||
platform_ids = set()
|
||||
for rd in game["release_dates"]:
|
||||
pid = rd.get("platform")
|
||||
if pid is not None:
|
||||
platform_ids.add(pid)
|
||||
if platform_ids:
|
||||
platforms = lookup_platform_names(list(platform_ids), headers)
|
||||
|
||||
game_dict = {
|
||||
"igdb_id": game.get("id"),
|
||||
"title": game.get("name"),
|
||||
@ -129,6 +155,7 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||
"release_date": release_date,
|
||||
"summary": game.get("summary"),
|
||||
"genres": genres,
|
||||
"platforms": platforms,
|
||||
}
|
||||
|
||||
return game_dict
|
||||
|
||||
0
vrobbler/apps/videogames/management/__init__.py
Normal file
0
vrobbler/apps/videogames/management/__init__.py
Normal file
@ -0,0 +1,529 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MISSING_ALL = [
|
||||
"cover",
|
||||
"screenshot",
|
||||
"summary",
|
||||
"rating",
|
||||
"release_date",
|
||||
"release_year",
|
||||
"igdb_id",
|
||||
"hltb_id",
|
||||
]
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda g: not bool(g.cover),
|
||||
"screenshot": lambda g: not bool(g.screenshot),
|
||||
"summary": lambda g: not g.summary,
|
||||
"rating": lambda g: g.rating is None,
|
||||
"release_date": lambda g: g.release_date is None,
|
||||
"release_year": lambda g: g.release_year is None,
|
||||
"igdb_id": lambda g: g.igdb_id is None,
|
||||
"hltb_id": lambda g: g.hltb_id is None,
|
||||
}
|
||||
|
||||
|
||||
def _game_matches(game, flags):
|
||||
if not flags:
|
||||
return False
|
||||
for flag in flags:
|
||||
fn = MISSING_GROUPS.get(flag)
|
||||
if fn and fn(game):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill missing metadata on video games from IGDB and HowLongToBeat"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of games to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to sleep between API calls (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-fetch metadata even if data already exists",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--game-id",
|
||||
type=int,
|
||||
help="Only process a specific game by ID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fix-broken-images",
|
||||
action="store_true",
|
||||
help="Check and refetch broken/deleted game images (cover, screenshot, hltb_cover)",
|
||||
)
|
||||
for flag in MISSING_ALL:
|
||||
parser.add_argument(
|
||||
f"--missing-{flag}",
|
||||
dest="missing_flags",
|
||||
action="append_const",
|
||||
const=flag,
|
||||
help=f"Process games missing {flag}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
dest="all_missing",
|
||||
help="Process games missing any metadata field",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from videogames.models import VideoGame
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
force = options["force"]
|
||||
game_id = options["game_id"]
|
||||
fix_broken_images = options.get("fix_broken_images", False)
|
||||
flags = options.get("missing_flags") or []
|
||||
all_missing = options["all_missing"]
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
|
||||
if not flags and not game_id and not force and not fix_broken_images:
|
||||
self.stdout.write(
|
||||
"No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
|
||||
)
|
||||
return
|
||||
|
||||
if game_id:
|
||||
qs = VideoGame.objects.filter(id=game_id)
|
||||
else:
|
||||
qs = VideoGame.objects.all()
|
||||
|
||||
if flags:
|
||||
qs = [g for g in qs.iterator() if _game_matches(g, flags)]
|
||||
else:
|
||||
qs = list(qs)
|
||||
|
||||
total = len(qs)
|
||||
self.stdout.write(f"Found {total} games to process")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
title_mismatches = []
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
stats = {
|
||||
"cover_fixed": 0,
|
||||
"screenshot_fixed": 0,
|
||||
"summary_fixed": 0,
|
||||
"rating_fixed": 0,
|
||||
"release_date_fixed": 0,
|
||||
"release_year_fixed": 0,
|
||||
"igdb_id_found": 0,
|
||||
"hltb_id_found": 0,
|
||||
"images_fixed": 0,
|
||||
}
|
||||
|
||||
enriched_any = bool(flags or game_id or force)
|
||||
|
||||
if enriched_any:
|
||||
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for game in batch:
|
||||
result = self._enrich_game(game, sleep_secs, force)
|
||||
self._check_retroarch_name(game, title_mismatches)
|
||||
if result:
|
||||
enriched += 1
|
||||
for key in stats:
|
||||
if result.get(key):
|
||||
stats[key] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
if fix_broken_images:
|
||||
broken_stats = self._fix_broken_images(qs, sleep_secs)
|
||||
stats["images_fixed"] = broken_stats["images_fixed"]
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Games enriched: {enriched}\n"
|
||||
f" Games skipped: {skipped}\n"
|
||||
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||
f" Screenshots fixed: {stats['screenshot_fixed']}\n"
|
||||
f" Summaries fixed: {stats['summary_fixed']}\n"
|
||||
f" Ratings fixed: {stats['rating_fixed']}\n"
|
||||
f" Release dates fixed: {stats['release_date_fixed']}\n"
|
||||
f" Release years fixed: {stats['release_year_fixed']}\n"
|
||||
f" IGDB IDs found: {stats['igdb_id_found']}\n"
|
||||
f" HLtB IDs found: {stats['hltb_id_found']}"
|
||||
)
|
||||
if fix_broken_images:
|
||||
self.stdout.write(f" Broken images fixed: {stats['images_fixed']}")
|
||||
|
||||
if title_mismatches:
|
||||
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
|
||||
for retroarch_name, title, game_id in title_mismatches:
|
||||
self.stdout.write(
|
||||
f" Game #{game_id}: retroarch_name={retroarch_name!r} vs title={title!r}"
|
||||
)
|
||||
|
||||
def _clean_retroarch_name(self, name):
|
||||
if not name:
|
||||
return ""
|
||||
name = name.strip()
|
||||
if "(" in name:
|
||||
name = name.split("(")[0].strip()
|
||||
return name
|
||||
|
||||
def _check_retroarch_name(self, game, mismatches):
|
||||
if not game.retroarch_name:
|
||||
return
|
||||
cleaned = self._clean_retroarch_name(game.retroarch_name)
|
||||
if cleaned.lower() != game.title.lower():
|
||||
mismatches.append((game.retroarch_name, game.title, game.id))
|
||||
if "retroarch-mismatch" not in game.tags.names():
|
||||
game.tags.add("retroarch-mismatch")
|
||||
self.stdout.write(
|
||||
f" [TAG] {game} — tagged as retroarch-mismatch"
|
||||
)
|
||||
|
||||
def _enrich_game(self, game, sleep_secs, force):
|
||||
from videogames.igdb import lookup_game_id_from_gdb, lookup_game_from_igdb
|
||||
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||
|
||||
search_name = self._clean_retroarch_name(game.retroarch_name) or game.title
|
||||
|
||||
changed = {}
|
||||
|
||||
if not game.hltb_id:
|
||||
hltb_data = None
|
||||
if search_name:
|
||||
hltb_data = lookup_game_from_hltb(search_name, search_by_title=True)
|
||||
time.sleep(sleep_secs)
|
||||
if not hltb_data and game.title and game.title != search_name:
|
||||
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||
time.sleep(sleep_secs)
|
||||
if hltb_data:
|
||||
result = self._apply_hltb_data(game, hltb_data, force)
|
||||
if result:
|
||||
changed.update(result)
|
||||
|
||||
igdb_data = None
|
||||
if not game.igdb_id and search_name:
|
||||
igdb_id = lookup_game_id_from_gdb(search_name)
|
||||
time.sleep(sleep_secs)
|
||||
if igdb_id:
|
||||
igdb_data = lookup_game_from_igdb(str(igdb_id))
|
||||
time.sleep(sleep_secs)
|
||||
elif game.igdb_id:
|
||||
igdb_data = lookup_game_from_igdb(str(game.igdb_id))
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
if igdb_data:
|
||||
igdb_title = igdb_data.get("title", "")
|
||||
igdb_title_clean = self._clean_retroarch_name(igdb_title)
|
||||
if igdb_title_clean.lower() == search_name.lower():
|
||||
if game.igdb_id is None and igdb_data.get("igdb_id"):
|
||||
game.igdb_id = int(igdb_data["igdb_id"])
|
||||
game.save(update_fields=["igdb_id"])
|
||||
changed["igdb_id_found"] = True
|
||||
self.stdout.write(f" [IGDB_ID] {game} — found IGDB ID {game.igdb_id}")
|
||||
result = self._apply_igdb_data(game, igdb_data, force)
|
||||
if result:
|
||||
changed.update(result)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" [IGDB] {game} — title mismatch (IGDB: {igdb_title!r} vs expected: {search_name!r}), re-searching…"
|
||||
)
|
||||
resolved = False
|
||||
for candidate in (search_name, game.title if game.title != search_name else None):
|
||||
if not candidate:
|
||||
continue
|
||||
new_id = lookup_game_id_from_gdb(candidate)
|
||||
time.sleep(sleep_secs)
|
||||
if not new_id:
|
||||
continue
|
||||
new_data = lookup_game_from_igdb(str(new_id))
|
||||
time.sleep(sleep_secs)
|
||||
if not new_data:
|
||||
continue
|
||||
new_title = new_data.get("title", "")
|
||||
new_title_clean = self._clean_retroarch_name(new_title)
|
||||
if new_title_clean.lower() == candidate.lower():
|
||||
game.igdb_id = int(new_id)
|
||||
if new_title and new_title != game.title:
|
||||
game.title = new_title
|
||||
changed["title_updated"] = True
|
||||
self.stdout.write(f" [TITLE] {game} — updated title to {new_title!r} from IGDB")
|
||||
game.save(update_fields=["igdb_id"] + (["title"] if changed.get("title_updated") else []))
|
||||
changed["igdb_id_found"] = True
|
||||
self.stdout.write(f" [IGDB_ID] {game} — re-found IGDB ID {game.igdb_id}")
|
||||
if "igdb-mismatch" in game.tags.names():
|
||||
game.tags.remove("igdb-mismatch")
|
||||
self.stdout.write(f" [TAG] {game} — removed igdb-mismatch tag")
|
||||
result = self._apply_igdb_data(game, new_data, force)
|
||||
if result:
|
||||
changed.update(result)
|
||||
resolved = True
|
||||
break
|
||||
|
||||
if not resolved and "igdb-mismatch" not in game.tags.names():
|
||||
game.tags.add("igdb-mismatch")
|
||||
self.stdout.write(f" [TAG] {game} — tagged igdb-mismatch")
|
||||
|
||||
# If retroarch-mismatch tag exists but no longer applies, remove it
|
||||
if "retroarch-mismatch" in game.tags.names():
|
||||
cleaned = self._clean_retroarch_name(game.retroarch_name or "")
|
||||
if cleaned.lower() == game.title.lower():
|
||||
game.tags.remove("retroarch-mismatch")
|
||||
self.stdout.write(f" [TAG] {game} — removed retroarch-mismatch tag (title now matches)")
|
||||
|
||||
return changed if changed else None
|
||||
|
||||
def _apply_igdb_data(self, game, data, force):
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import requests
|
||||
|
||||
changed = {
|
||||
"cover_fixed": False,
|
||||
"screenshot_fixed": False,
|
||||
"summary_fixed": False,
|
||||
"rating_fixed": False,
|
||||
"release_date_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
if data.get("alternative_name") and not game.alternative_name:
|
||||
game.alternative_name = data["alternative_name"]
|
||||
update_fields.append("alternative_name")
|
||||
|
||||
if data.get("summary") and (not game.summary or force):
|
||||
game.summary = data["summary"]
|
||||
update_fields.append("summary")
|
||||
changed["summary_fixed"] = True
|
||||
|
||||
if data.get("rating") is not None and (game.rating is None or force):
|
||||
game.rating = data["rating"]
|
||||
update_fields.append("rating")
|
||||
changed["rating_fixed"] = True
|
||||
|
||||
if data.get("rating_count") is not None and (game.rating_count is None or force):
|
||||
game.rating_count = data["rating_count"]
|
||||
update_fields.append("rating_count")
|
||||
|
||||
if data.get("release_date") and (game.release_date is None or force):
|
||||
game.release_date = data["release_date"]
|
||||
update_fields.append("release_date")
|
||||
changed["release_date_fixed"] = True
|
||||
|
||||
if update_fields:
|
||||
game.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [IGDB] {game} — {', '.join(update_fields)}")
|
||||
|
||||
cover_url = data.get("cover_url")
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||
changed["cover_fixed"] = True
|
||||
self.stdout.write(f" [COVER] {game} — cover saved from IGDB")
|
||||
|
||||
screenshot_url = data.get("screenshot_url")
|
||||
if screenshot_url:
|
||||
r = requests.get(screenshot_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||
changed["screenshot_fixed"] = True
|
||||
self.stdout.write(f" [SCREENSHOT] {game} — screenshot saved from IGDB")
|
||||
|
||||
genres = data.get("genres", [])
|
||||
if genres:
|
||||
existing = set(game.genre.names())
|
||||
new_genres = [g for g in genres if g not in existing]
|
||||
if new_genres:
|
||||
game.genre.add(*new_genres)
|
||||
self.stdout.write(f" [GENRES] {game} — added {len(new_genres)} genres")
|
||||
|
||||
platforms = data.get("platforms", [])
|
||||
if platforms:
|
||||
existing = set(game.platforms.values_list("name", flat=True))
|
||||
new_platforms = [p for p in platforms if p not in existing]
|
||||
if new_platforms:
|
||||
from videogames.models import VideoGamePlatform
|
||||
|
||||
for name in new_platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||
|
||||
if "igdb-enriched" not in game.tags.names():
|
||||
game.tags.add("igdb-enriched")
|
||||
self.stdout.write(f" [TAG] {game} — tagged igdb-enriched")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
|
||||
def _apply_hltb_data(self, game, data, force):
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import requests
|
||||
|
||||
changed = {
|
||||
"hltb_id_found": False,
|
||||
"release_year_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
hltb_title = data.get("title", "")
|
||||
if hltb_title and hltb_title != game.title:
|
||||
game.title = hltb_title
|
||||
update_fields.append("title")
|
||||
self.stdout.write(f" [TITLE] {game} — updated title to {hltb_title!r}")
|
||||
|
||||
if data.get("hltb_id") and (game.hltb_id is None or force):
|
||||
game.hltb_id = data["hltb_id"]
|
||||
update_fields.append("hltb_id")
|
||||
changed["hltb_id_found"] = True
|
||||
self.stdout.write(f" [HLTB_ID] {game} — found HLtB ID {data['hltb_id']}")
|
||||
|
||||
if data.get("release_year") and (game.release_year is None or force):
|
||||
game.release_year = data["release_year"]
|
||||
update_fields.append("release_year")
|
||||
changed["release_year_fixed"] = True
|
||||
|
||||
if data.get("main_story_time") and (game.main_story_time is None or force):
|
||||
game.main_story_time = data["main_story_time"]
|
||||
update_fields.append("main_story_time")
|
||||
|
||||
if data.get("main_extra_time") and (game.main_extra_time is None or force):
|
||||
game.main_extra_time = data["main_extra_time"]
|
||||
update_fields.append("main_extra_time")
|
||||
|
||||
if data.get("completionist_time") and (game.completionist_time is None or force):
|
||||
game.completionist_time = data["completionist_time"]
|
||||
update_fields.append("completionist_time")
|
||||
|
||||
if data.get("hltb_score") is not None and (game.hltb_score is None or force):
|
||||
game.hltb_score = data["hltb_score"]
|
||||
update_fields.append("hltb_score")
|
||||
|
||||
if update_fields:
|
||||
game.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [HLTB] {game} — {', '.join(update_fields)}")
|
||||
|
||||
cover_url = data.get("cover_url")
|
||||
if cover_url:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
self.stdout.write(f" [HLTB_COVER] {game} — cover saved from HLtB")
|
||||
|
||||
platforms = data.get("platforms", [])
|
||||
if platforms:
|
||||
existing = set(game.platforms.values_list("name", flat=True))
|
||||
new_platforms = [p for p in platforms if p not in existing]
|
||||
if new_platforms:
|
||||
from videogames.models import VideoGamePlatform
|
||||
|
||||
for name in new_platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||
|
||||
if "hltb-enriched" not in game.tags.names():
|
||||
game.tags.add("hltb-enriched")
|
||||
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
|
||||
def _fix_broken_images(self, games, sleep_secs):
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import requests
|
||||
|
||||
from videogames.igdb import lookup_game_from_igdb
|
||||
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||
|
||||
stats = {"cover_fixed": 0, "screenshot_fixed": 0, "images_fixed": 0}
|
||||
|
||||
for game in games:
|
||||
for field_name, source in [
|
||||
("cover", "igdb"),
|
||||
("screenshot", "igdb"),
|
||||
("hltb_cover", "hltb"),
|
||||
]:
|
||||
field = getattr(game, field_name)
|
||||
if not field.name:
|
||||
continue
|
||||
if field.storage.exists(field.name):
|
||||
continue
|
||||
|
||||
self.stdout.write(
|
||||
f" [IMAGE] {game} — {field_name} is broken (file missing), refetching…"
|
||||
)
|
||||
|
||||
if source == "igdb" and game.igdb_id:
|
||||
data = lookup_game_from_igdb(str(game.igdb_id))
|
||||
time.sleep(sleep_secs)
|
||||
if not data:
|
||||
continue
|
||||
url = data.get("cover_url" if field_name == "cover" else "screenshot_url")
|
||||
if not url:
|
||||
continue
|
||||
r = requests.get(url)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
getattr(game, field_name).save(fname, ContentFile(r.content), save=True)
|
||||
stats["images_fixed"] += 1
|
||||
self.stdout.write(f" [IMAGE] {game} — {field_name} refetched from IGDB")
|
||||
|
||||
elif source == "hltb" and game.hltb_id:
|
||||
data = lookup_game_from_hltb(str(game.hltb_id))
|
||||
time.sleep(sleep_secs)
|
||||
if not data:
|
||||
continue
|
||||
url = data.get("cover_url")
|
||||
if not url:
|
||||
continue
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
stats["images_fixed"] += 1
|
||||
self.stdout.write(f" [IMAGE] {game} — hltb_cover refetched from HLtB")
|
||||
|
||||
return stats
|
||||
@ -215,12 +215,16 @@ class VideoGame(LongPlayScrobblableMixin):
|
||||
def fix_metadata(self, force_update: bool = False):
|
||||
from videogames.utils import (
|
||||
get_or_create_videogame,
|
||||
load_game_data_from_hltb,
|
||||
load_game_data_from_igdb,
|
||||
)
|
||||
|
||||
if self.hltb_id and force_update:
|
||||
get_or_create_videogame(str(self.hltb_id), force_update)
|
||||
|
||||
if not self.hltb_id:
|
||||
load_game_data_from_hltb(self.id)
|
||||
|
||||
if not self.igdb_id:
|
||||
# This almost never works without intervention
|
||||
self.igdb_id = lookup_game_id_from_gdb(self.title)
|
||||
|
||||
@ -153,6 +153,13 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
||||
continue
|
||||
|
||||
logger.info(f"Queued scrobble for game {found_game.id}")
|
||||
|
||||
log_data = {"emulated": True}
|
||||
if last_scrobble and last_scrobble.log:
|
||||
prev = last_scrobble.log
|
||||
if prev.get("emulator"):
|
||||
log_data["emulator"] = prev["emulator"]
|
||||
|
||||
new_scrobbles.append(
|
||||
Scrobble(
|
||||
video_game_id=found_game.id,
|
||||
@ -168,6 +175,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
||||
user_id=user_id,
|
||||
source="Retroarch",
|
||||
media_type=Scrobble.MediaType.VIDEO_GAME,
|
||||
log=log_data,
|
||||
)
|
||||
)
|
||||
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
|
||||
@ -7,8 +7,6 @@ from videogames.howlongtobeat import lookup_game_from_hltb
|
||||
from videogames.igdb import lookup_game_from_igdb
|
||||
from videogames.models import VideoGame, VideoGamePlatform
|
||||
|
||||
from vrobbler.apps.videogames.exceptions import GameNotFound
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -16,22 +14,33 @@ def get_or_create_videogame(
|
||||
name_or_id: str,
|
||||
force_update: bool = False,
|
||||
) -> Optional[VideoGame]:
|
||||
"""Look up game by name or ID from HowLongToBeat"""
|
||||
"""Look up game by name or ID from HowLongToBeat, then enrich with IGDB"""
|
||||
|
||||
game_dict = lookup_game_from_hltb(name_or_id)
|
||||
hltb_data = lookup_game_from_hltb(name_or_id)
|
||||
|
||||
if not game_dict:
|
||||
game_dict = lookup_game_from_igdb(name_or_id)
|
||||
if hltb_data:
|
||||
game = _create_update_from_dict(hltb_data, force_update)
|
||||
else:
|
||||
igdb_data = lookup_game_from_igdb(name_or_id)
|
||||
if igdb_data:
|
||||
game = _create_update_from_dict(igdb_data, force_update)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not game_dict:
|
||||
return
|
||||
if game:
|
||||
game.fix_metadata()
|
||||
return game
|
||||
|
||||
|
||||
def _create_update_from_dict(
|
||||
game_dict: dict, force_update: bool = False
|
||||
) -> Optional[VideoGame]:
|
||||
|
||||
# Create missing platforms and prep for loading after create
|
||||
platform_ids = []
|
||||
if "platforms" in game_dict.keys():
|
||||
platforms = game_dict.get("platforms", [])
|
||||
if platforms:
|
||||
for platform in game_dict.get("platforms", []):
|
||||
for platform in platforms:
|
||||
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
|
||||
platform_ids.append(p.id)
|
||||
game_dict.pop("platforms")
|
||||
@ -48,7 +57,7 @@ def get_or_create_videogame(
|
||||
|
||||
title = game_dict.get("title")
|
||||
if not title:
|
||||
raise GameNotFound(name_or_id)
|
||||
return None
|
||||
|
||||
hltb_id = game_dict.get("hltb_id")
|
||||
igdb_id = game_dict.get("igdb_id")
|
||||
@ -69,21 +78,19 @@ def get_or_create_videogame(
|
||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||
game.refresh_from_db()
|
||||
|
||||
# Associate plaforms
|
||||
if platform_ids:
|
||||
game.platforms.add(*platform_ids)
|
||||
|
||||
if genres:
|
||||
game.genre.add(*genres)
|
||||
|
||||
if not game.screenshot and screenshot_url:
|
||||
if screenshot_url:
|
||||
r = requests.get(screenshot_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not game.hltb_cover:
|
||||
if cover_url:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
logger.debug(r.status_code)
|
||||
@ -91,12 +98,89 @@ def get_or_create_videogame(
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
logger.debug("Loaded cover image from HLtB")
|
||||
game.fix_metadata()
|
||||
|
||||
tag = "hltb-enriched" if hltb_id else "igdb-enriched"
|
||||
if tag not in game.tags.names():
|
||||
game.tags.add(tag)
|
||||
logger.info(f"Game {game} tagged {tag}")
|
||||
|
||||
return game
|
||||
|
||||
|
||||
def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoGame]:
|
||||
def load_game_data_from_hltb(
|
||||
game_id: int, expected_title: str = ""
|
||||
) -> Optional[VideoGame]:
|
||||
"""Look up HLtB data for an existing game and apply it"""
|
||||
game = VideoGame.objects.filter(id=game_id).first()
|
||||
if not game:
|
||||
logger.warn(f"Video game with ID {game_id} not found")
|
||||
return
|
||||
|
||||
logger.info(f"Looking up HLtB data for {game}")
|
||||
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||
if not hltb_data:
|
||||
logger.warn(f"No HLtB data found for {game}")
|
||||
return
|
||||
|
||||
update_fields = []
|
||||
|
||||
hltb_title = hltb_data.get("title", "")
|
||||
if hltb_title and hltb_title != game.title:
|
||||
game.title = hltb_title
|
||||
update_fields.append("title")
|
||||
logger.info(f"Game {game.id} title updated to {hltb_title!r}")
|
||||
|
||||
if hltb_data.get("hltb_id") and (game.hltb_id is None):
|
||||
game.hltb_id = hltb_data["hltb_id"]
|
||||
update_fields.append("hltb_id")
|
||||
|
||||
if hltb_data.get("release_year") and (game.release_year is None):
|
||||
game.release_year = hltb_data["release_year"]
|
||||
update_fields.append("release_year")
|
||||
|
||||
if hltb_data.get("main_story_time") and (game.main_story_time is None):
|
||||
game.main_story_time = hltb_data["main_story_time"]
|
||||
update_fields.append("main_story_time")
|
||||
|
||||
if hltb_data.get("main_extra_time") and (game.main_extra_time is None):
|
||||
game.main_extra_time = hltb_data["main_extra_time"]
|
||||
update_fields.append("main_extra_time")
|
||||
|
||||
if hltb_data.get("completionist_time") and (game.completionist_time is None):
|
||||
game.completionist_time = hltb_data["completionist_time"]
|
||||
update_fields.append("completionist_time")
|
||||
|
||||
if hltb_data.get("hltb_score") is not None and (game.hltb_score is None):
|
||||
game.hltb_score = hltb_data["hltb_score"]
|
||||
update_fields.append("hltb_score")
|
||||
|
||||
if update_fields:
|
||||
game.save(update_fields=update_fields)
|
||||
|
||||
platforms = hltb_data.get("platforms", [])
|
||||
if platforms:
|
||||
for name in platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
|
||||
cover_url = hltb_data.get("cover_url")
|
||||
if cover_url:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if "hltb-enriched" not in game.tags.names():
|
||||
game.tags.add("hltb-enriched")
|
||||
logger.info(f"Game {game} tagged hltb-enriched")
|
||||
|
||||
return game
|
||||
|
||||
|
||||
def load_game_data_from_igdb(
|
||||
game_id: int, igdb_id: str = "", expected_title: str = ""
|
||||
) -> Optional[VideoGame]:
|
||||
"""Look up game, if it doesn't exist, lookup data from igdb"""
|
||||
game = VideoGame.objects.filter(id=game_id).first()
|
||||
if not game:
|
||||
@ -116,25 +200,68 @@ def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoG
|
||||
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
|
||||
return
|
||||
|
||||
igdb_title = game_dict.get("title", "")
|
||||
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||
expected = expected_title or game.title
|
||||
if igdb_title_clean.lower() != expected.lower():
|
||||
logger.info(
|
||||
f"IGDB title {igdb_title!r} doesn't match expected {expected!r} for {game} — re-searching…"
|
||||
)
|
||||
from videogames.igdb import lookup_game_id_from_gdb
|
||||
|
||||
new_id = lookup_game_id_from_gdb(expected)
|
||||
if new_id:
|
||||
new_data = lookup_game_from_igdb(str(new_id))
|
||||
if new_data:
|
||||
new_title = new_data.get("title", "")
|
||||
new_title_clean = new_title.split(" (")[0].strip() if " (" in new_title else new_title
|
||||
if new_title_clean.lower() == expected.lower():
|
||||
game_dict = new_data
|
||||
igdb_id = int(new_id)
|
||||
if game.igdb_id != igdb_id:
|
||||
game.igdb_id = igdb_id
|
||||
game.save(update_fields=["igdb_id"])
|
||||
logger.info(f"Game {game} IGDB ID updated to {igdb_id}")
|
||||
|
||||
igdb_title = game_dict.get("title", "")
|
||||
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||
if igdb_title_clean.lower() != expected.lower():
|
||||
if "igdb-mismatch" not in game.tags.names():
|
||||
game.tags.add("igdb-mismatch")
|
||||
logger.info(
|
||||
f"Game {game} tagged igdb-mismatch (IGDB: {igdb_title!r} vs expected: {expected!r})"
|
||||
)
|
||||
return
|
||||
|
||||
screenshot_url = game_dict.pop("screenshot_url")
|
||||
cover_url = game_dict.pop("cover_url")
|
||||
genres = game_dict.pop("genres")
|
||||
platforms = game_dict.pop("platforms", [])
|
||||
|
||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||
game.refresh_from_db()
|
||||
|
||||
game.genre.add(*genres)
|
||||
|
||||
if not game.screenshot and screenshot_url:
|
||||
if platforms:
|
||||
for name in platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
|
||||
if screenshot_url:
|
||||
r = requests.get(screenshot_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if not game.cover and cover_url:
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if "igdb-enriched" not in game.tags.names():
|
||||
game.tags.add("igdb-enriched")
|
||||
logger.info(f"Game {game} tagged igdb-enriched")
|
||||
|
||||
return game
|
||||
|
||||
@ -122,6 +122,15 @@ CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_RESULT_EXTENDED = True
|
||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
||||
|
||||
CELERY_TASK_CREATE_MISSING_QUEUES = True
|
||||
CELERY_TASK_ROUTES = {
|
||||
"scrobbles.tasks.update_charts_for_timestamp": {"queue": "charts"},
|
||||
"scrobbles.tasks.create_yesterdays_charts": {"queue": "charts"},
|
||||
"scrobbles.tasks.rebuild_weekly_charts": {"queue": "charts"},
|
||||
"scrobbles.tasks.rebuild_monthly_charts": {"queue": "charts"},
|
||||
"scrobbles.tasks.rebuild_yearly_charts": {"queue": "charts"},
|
||||
}
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"build-yesterdays-charts": {
|
||||
"task": "scrobbles.tasks.create_yesterdays_charts",
|
||||
|
||||
Reference in New Issue
Block a user