Compare commits

...

9 Commits
55.1 ... 55.4

Author SHA1 Message Date
407d570c82 [release] Bump to version 55.4
All checks were successful
build / test (push) Successful in 2m12s
deploy / test (push) Successful in 2m4s
deploy / build-and-deploy (push) Successful in 1m22s
- Tighten up the speed of startup and first request
2026-06-19 13:41:23 -04:00
033239260f [perf] Add caching and lock protections
All checks were successful
build / test (push) Successful in 1m57s
2026-06-19 13:37:30 -04:00
9f854dc735 [release] Bump to version 55.3
All checks were successful
build / test (push) Successful in 2m6s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Successful in 39s
- =alt_names= feature for artists (commented out / dead code)
- Put chart rebuilds in a lower priority task queue
- Check for existing book scrobble and update page count
2026-06-19 01:14:39 -04:00
f29272a853 [music] Clean up dead code
Some checks failed
build / test (push) Has been cancelled
2026-06-19 01:13:28 -04:00
4e56d9420a [settings] Put chart rebuilds in their own queue 2026-06-19 01:12:50 -04:00
852a257159 [scrobbles] Clean up a TODO already done
All checks were successful
build / test (push) Successful in 1m59s
2026-06-18 16:59:03 -04:00
68ff230f13 [release] Bump to version 55.2
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 2m0s
deploy / build-and-deploy (push) Successful in 30s
- Fix bug in scrobble id in calendar view
- Video game cleanup script should clear out broken images
2026-06-18 15:27:29 -04:00
57a952a6d1 [templates] Fix bug in calendar view
Some checks failed
build / test (push) Has been cancelled
2026-06-18 15:27:09 -04:00
718fcf7392 [videogames] Fix broken images in cleanup
All checks were successful
build / test (push) Successful in 2m0s
2026-06-18 15:24:47 -04:00
18 changed files with 512 additions and 130 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources *** Metadata sources
**** Scraper **** Scraper
* Backlog [0/21] :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: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES: :PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb :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 - ~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: ** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
:PROPERTIES: :PROPERTIES:
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54 :ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
@ -591,6 +603,55 @@ a helper method to create board game scrobbles given a json blob. It's
independent of the email flow it was originally creatdd for independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types: ** TODO [#B] Is there way to create unique slugs for media instances :media_types:
* Version 55.4 [1/1]
** DONE [#A] Tighten up the speed of startup and first request :perf:
:PROPERTIES:
:ID: 9ee8834c-6be2-d04b-df6d-56375504083f
:END:
* 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] * Version 55.1 [1/1]
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames: ** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
:PROPERTIES: :PROPERTIES:

View File

@ -1,2 +1,2 @@
web: python manage.py runserver 0.0.0.0:8014 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

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "55.1" version = "55.4"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -4,9 +4,15 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from vrobbler import context_processors
from vrobbler.context_processors import version_info from vrobbler.context_processors import version_info
@pytest.fixture(autouse=True)
def reset_git_cache():
context_processors._GIT_COMMIT = None
@pytest.fixture @pytest.fixture
def mock_request(): def mock_request():
return MagicMock() return MagicMock()

View File

@ -0,0 +1,109 @@
# Generated by Django 4.2.29 on 2026-06-19 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("charts", "0002_chartrecord_charts_char_user_id_1adcde_idx_and_more"),
]
operations = [
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("artist__isnull", False)),
fields=("user", "year", "month", "week", "day", "artist"),
name="unique_chart_artist_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("album__isnull", False)),
fields=("user", "year", "month", "week", "day", "album"),
name="unique_chart_album_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("track__isnull", False)),
fields=("user", "year", "month", "week", "day", "track"),
name="unique_chart_track_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("tv_series__isnull", False)),
fields=("user", "year", "month", "week", "day", "tv_series"),
name="unique_chart_tv_series_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("video__isnull", False)),
fields=("user", "year", "month", "week", "day", "video"),
name="unique_chart_video_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("podcast__isnull", False)),
fields=("user", "year", "month", "week", "day", "podcast"),
name="unique_chart_podcast_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("podcast_episode__isnull", False)),
fields=("user", "year", "month", "week", "day", "podcast_episode"),
name="unique_chart_podcast_episode_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("board_game__isnull", False)),
fields=("user", "year", "month", "week", "day", "board_game"),
name="unique_chart_board_game_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("trail__isnull", False)),
fields=("user", "year", "month", "week", "day", "trail"),
name="unique_chart_trail_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("geo_location__isnull", False)),
fields=("user", "year", "month", "week", "day", "geo_location"),
name="unique_chart_geo_location_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("food__isnull", False)),
fields=("user", "year", "month", "week", "day", "food"),
name="unique_chart_food_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("book__isnull", False)),
fields=("user", "year", "month", "week", "day", "book"),
name="unique_chart_book_period",
),
),
]

View File

@ -2,6 +2,7 @@ import calendar
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
@ -84,6 +85,68 @@ class ChartRecord(TimeStampedModel):
models.Index(fields=["user", "year", "month", "day", "album", "rank"]), models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]), models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
] ]
constraints = [
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "artist"],
condition=Q(artist__isnull=False),
name="unique_chart_artist_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "album"],
condition=Q(album__isnull=False),
name="unique_chart_album_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "track"],
condition=Q(track__isnull=False),
name="unique_chart_track_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "tv_series"],
condition=Q(tv_series__isnull=False),
name="unique_chart_tv_series_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "video"],
condition=Q(video__isnull=False),
name="unique_chart_video_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "podcast"],
condition=Q(podcast__isnull=False),
name="unique_chart_podcast_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "podcast_episode"],
condition=Q(podcast_episode__isnull=False),
name="unique_chart_podcast_episode_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "board_game"],
condition=Q(board_game__isnull=False),
name="unique_chart_board_game_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "trail"],
condition=Q(trail__isnull=False),
name="unique_chart_trail_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "geo_location"],
condition=Q(geo_location__isnull=False),
name="unique_chart_geo_location_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "food"],
condition=Q(food__isnull=False),
name="unique_chart_food_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "book"],
condition=Q(book__isnull=False),
name="unique_chart_book_period",
),
]
@property @property
def media_obj(self): def media_obj(self):

View File

@ -6,6 +6,7 @@ from typing import Optional
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.db.models import Count, Q from django.db.models import Count, Q
from django.utils import timezone from django.utils import timezone
@ -186,60 +187,64 @@ def build_charts(
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)} ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
media_field = f"{media_type}_id" media_field = f"{media_type}_id"
records_to_create = []
records_to_update = []
existing = ChartRecord.objects.filter( with transaction.atomic():
period_filter, user=user, **{media_field + "__isnull": False} records_to_create = []
) records_to_update = []
existing_by_media_id = {getattr(r, media_field): r for r in existing}
found_media_ids = set()
for result in results: existing = ChartRecord.objects.select_for_update().filter(
media_id = result[config["values"]] period_filter, user=user, **{media_field + "__isnull": False}
if media_id is None:
continue
found_media_ids.add(media_id)
chart_record_data = {
"user_id": user.id,
"year": year,
"month": month,
"week": week,
"day": day,
"rank": ranks[result["scrobble_count"]],
"count": result["scrobble_count"],
}
chart_record_data[media_field] = media_id
if media_id in existing_by_media_id:
existing_record = existing_by_media_id[media_id]
existing_record.rank = chart_record_data["rank"]
existing_record.count = chart_record_data["count"]
records_to_update.append(existing_record)
else:
records_to_create.append(ChartRecord(**chart_record_data))
ids_to_delete = [
r.id for r in existing if getattr(r, media_field) not in found_media_ids
]
if ids_to_delete:
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
if records_to_update:
ChartRecord.objects.bulk_update(
records_to_update, ["rank", "count"], batch_size=500
) )
existing_by_media_id = {getattr(r, media_field): r for r in existing}
found_media_ids = set()
if records_to_create: for result in results:
ChartRecord.objects.bulk_create(records_to_create, batch_size=500) media_id = result[config["values"]]
if media_id is None:
continue
logger.info( found_media_ids.add(media_id)
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
f"chart records for {media_type}, period " chart_record_data = {
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}" "user_id": user.id,
) "year": year,
"month": month,
"week": week,
"day": day,
"rank": ranks[result["scrobble_count"]],
"count": result["scrobble_count"],
}
chart_record_data[media_field] = media_id
if media_id in existing_by_media_id:
existing_record = existing_by_media_id[media_id]
existing_record.rank = chart_record_data["rank"]
existing_record.count = chart_record_data["count"]
records_to_update.append(existing_record)
else:
records_to_create.append(ChartRecord(**chart_record_data))
ids_to_delete = [
r.id
for r in existing
if getattr(r, media_field) not in found_media_ids
]
if ids_to_delete:
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
if records_to_update:
ChartRecord.objects.bulk_update(
records_to_update, ["rank", "count"], batch_size=500
)
if records_to_create:
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
logger.info(
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
f"chart records for {media_type}, period "
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
)
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None: def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:

View File

@ -1,8 +1,22 @@
from django.core.cache import cache
from music.models import Artist, Album from music.models import Artist, Album
CACHE_TTL = 300
def music_lists(request): def music_lists(request):
artist_list = cache.get("music_lists_artist_list")
if artist_list is None:
artist_list = list(Artist.objects.all().only("id", "name"))
cache.set("music_lists_artist_list", artist_list, CACHE_TTL)
album_list = cache.get("music_lists_album_list")
if album_list is None:
album_list = list(Album.objects.all().only("id", "name"))
cache.set("music_lists_album_list", album_list, CACHE_TTL)
return { return {
"artist_list": Artist.objects.all(), "artist_list": artist_list,
"album_list": Album.objects.all(), "album_list": album_list,
} }

View File

@ -236,19 +236,6 @@ class Artist(TimeStampedModel):
) )
artist.fix_metadata() 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 return artist

View File

@ -1,4 +1,5 @@
import pytz import pytz
from django.core.cache import cache
from django.utils import timezone from django.utils import timezone
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
@ -19,6 +20,8 @@ MONTH_COLORS = [
"#db7a7a", # Dec "#db7a7a", # Dec
] ]
CACHE_TTL = 60
def month_color(request): def month_color(request):
from datetime import date from datetime import date
@ -27,15 +30,25 @@ def month_color(request):
def now_playing(request): def now_playing(request):
user = request.user user = request.user
now = timezone.now()
if not user.is_authenticated: if not user.is_authenticated:
return {} return {}
return {
"now_playing_list": Scrobble.objects.filter( cache_key = f"now_playing_list_{user.id}"
in_progress=True, now_playing_list = cache.get(cache_key)
is_paused=False, if now_playing_list is None:
user=user, now_playing_list = list(
).exclude( Scrobble.objects.filter(
media_type__in=EXCLUDE_FROM_NOW_PLAYING, in_progress=True,
is_paused=False,
user=user,
)
.exclude(
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
)
.select_related("track", "video", "podcast_episode")
) )
cache.set(cache_key, now_playing_list, CACHE_TTL)
return {
"now_playing_list": now_playing_list,
} }

View File

@ -330,8 +330,6 @@ def manual_scrobble_book(
source = READCOMICSONLINE_URL.replace("https://", "") 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) book = Book.find_or_create(title, url=url, enrich=True)
scrobble_dict = { scrobble_dict = {

View File

@ -1,5 +1,6 @@
import logging import logging
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
@ -52,6 +53,11 @@ def _update_charts_for_timestamp(user, ts):
if ts is None: if ts is None:
return return
lock_key = f"chart_update_{user.id}"
if not cache.add(lock_key, "locked", timeout=30):
logger.info(f"Chart update already queued for user {user.id}, skipping")
return
if timezone.is_naive(ts): if timezone.is_naive(ts):
ts = timezone.make_aware(ts) ts = timezone.make_aware(ts)

View File

@ -12,6 +12,7 @@ from charts.utils import (
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -241,6 +242,11 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
logger.error(f"User with id {user_id} not found") logger.error(f"User with id {user_id} not found")
return return
lock_key = f"chart_update_running_{user_id}"
if not cache.add(lock_key, "locked", timeout=300):
logger.info(f"Chart update already running for user {user_id}, skipping")
return
try: try:
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES) build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES) build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
@ -250,6 +256,8 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
logger.info(f"[charts] Updated charts for {user} on {date_str}") logger.info(f"[charts] Updated charts for {user} on {date_str}")
except Exception as e: except Exception as e:
logger.error(f"[charts] Failed to update charts: {e}") logger.error(f"[charts] Failed to update charts: {e}")
finally:
cache.delete(lock_key)
@shared_task @shared_task

View File

@ -1717,6 +1717,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
for scrobble in day_map[day_num]: for scrobble in day_map[day_num]:
day_scrobbles.append( day_scrobbles.append(
{ {
"id": scrobble.pk,
"uuid": scrobble.uuid, "uuid": scrobble.uuid,
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"), "emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
"title": ( "title": (

View File

@ -69,6 +69,11 @@ class Command(BaseCommand):
type=int, type=int,
help="Only process a specific game by ID", 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: for flag in MISSING_ALL:
parser.add_argument( parser.add_argument(
f"--missing-{flag}", f"--missing-{flag}",
@ -92,15 +97,16 @@ class Command(BaseCommand):
sleep_secs = options["sleep"] sleep_secs = options["sleep"]
force = options["force"] force = options["force"]
game_id = options["game_id"] game_id = options["game_id"]
fix_broken_images = options.get("fix_broken_images", False)
flags = options.get("missing_flags") or [] flags = options.get("missing_flags") or []
all_missing = options["all_missing"] all_missing = options["all_missing"]
if all_missing: if all_missing:
flags = MISSING_ALL flags = MISSING_ALL
if not flags and not game_id and not force: if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write( self.stdout.write(
"No filters specified. Use --all, --missing-*, --game-id, or --force." "No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
) )
return return
@ -135,25 +141,33 @@ class Command(BaseCommand):
"release_year_fixed": 0, "release_year_fixed": 0,
"igdb_id_found": 0, "igdb_id_found": 0,
"hltb_id_found": 0, "hltb_id_found": 0,
"images_fixed": 0,
} }
for batch_num, offset in enumerate(range(0, len(qs), batch_size)): enriched_any = bool(flags or game_id or force)
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( if enriched_any:
f" Batch {batch_num + 1}: {offset + len(batch)}/{total}" for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
f"enriched: {enriched}, skipped: {skipped}" 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( self.stdout.write(
f"\nResults (commit={commit}):\n" f"\nResults (commit={commit}):\n"
@ -168,6 +182,8 @@ class Command(BaseCommand):
f" IGDB IDs found: {stats['igdb_id_found']}\n" f" IGDB IDs found: {stats['igdb_id_found']}\n"
f" HLtB IDs found: {stats['hltb_id_found']}" 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: if title_mismatches:
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):") self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
@ -450,3 +466,64 @@ class Command(BaseCommand):
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched") self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
return changed if any(changed.values()) else None 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

View File

@ -1,8 +1,24 @@
from django.core.cache import cache
from videos.models import Video, Series from videos.models import Video, Series
CACHE_TTL = 300
def video_lists(request): def video_lists(request):
movie_list = cache.get("video_lists_movie_list")
if movie_list is None:
movie_list = list(
Video.objects.filter(video_type=Video.VideoType.MOVIE).only("id", "title")
)
cache.set("video_lists_movie_list", movie_list, CACHE_TTL)
series_list = cache.get("video_lists_series_list")
if series_list is None:
series_list = list(Series.objects.all().only("id", "name"))
cache.set("video_lists_series_list", series_list, CACHE_TTL)
return { return {
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE), "movie_list": movie_list,
"series_list": Series.objects.all(), "series_list": series_list,
} }

View File

@ -4,46 +4,55 @@ from importlib.metadata import version as get_version
from pathlib import Path from pathlib import Path
_GIT_COMMIT = None
def version_info(request): def version_info(request):
global _GIT_COMMIT
try: try:
app_version = get_version("vrobbler") app_version = get_version("vrobbler")
except Exception: except Exception:
app_version = "unknown" app_version = "unknown"
commit = os.environ.get("VROBBLER_COMMIT") commit = os.environ.get("VROBBLER_COMMIT")
if not commit: if commit:
# Try to import from _commit.py module first return {"app_version": app_version, "git_commit": commit}
if _GIT_COMMIT is not None:
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
try:
from vrobbler._commit import commit as _commit
except ImportError:
pass
else:
if _commit and _commit != "unknown":
_GIT_COMMIT = _commit
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
commit_file = Path("/var/lib/vrobbler/commit.txt")
if commit_file.exists():
try: try:
from vrobbler._commit import commit as _commit commit = commit_file.read_text().strip()
except ImportError: if commit:
_GIT_COMMIT = commit
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
except OSError:
pass pass
else:
if _commit and _commit != "unknown":
return {"app_version": app_version, "git_commit": _commit}
# Try to read from commit file (written during deploy) PROJECT_ROOT = Path(__file__).resolve().parent.parent
commit_file = Path("/var/lib/vrobbler/commit.txt") try:
if commit_file.exists(): _GIT_COMMIT = (
try: subprocess.check_output(
commit = commit_file.read_text().strip() ["git", "rev-parse", "--short", "HEAD"],
if commit: cwd=PROJECT_ROOT,
return {"app_version": app_version, "git_commit": commit} stderr=subprocess.DEVNULL,
except OSError:
pass
# Fall back to git command
PROJECT_ROOT = Path(__file__).resolve().parent.parent
try:
commit = (
subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=PROJECT_ROOT,
stderr=subprocess.DEVNULL,
)
.decode("utf-8")
.strip()
) )
except (subprocess.SubprocessError, FileNotFoundError): .decode("utf-8")
commit = "unknown" .strip()
)
except (subprocess.SubprocessError, FileNotFoundError):
_GIT_COMMIT = "unknown"
return {"app_version": app_version, "git_commit": commit} return {"app_version": app_version, "git_commit": _GIT_COMMIT}

View File

@ -122,6 +122,15 @@ CELERY_ACCEPT_CONTENT = ["json"]
CELERY_RESULT_EXTENDED = True CELERY_RESULT_EXTENDED = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = 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 = { CELERY_BEAT_SCHEDULE = {
"build-yesterdays-charts": { "build-yesterdays-charts": {
"task": "scrobbles.tasks.create_yesterdays_charts", "task": "scrobbles.tasks.create_yesterdays_charts",
@ -352,11 +361,11 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/ # https://docs.djangoproject.com/en/3.1/howto/static-files/
# #
from storages.backends import s3boto3
USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY
if USE_S3_STORAGE: if USE_S3_STORAGE:
from storages.backends import s3boto3
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "") AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "")
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "") AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID") AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID")