Compare commits

...

19 Commits
58.4 ... 59.2

Author SHA1 Message Date
2c481bd53a [release] Bump to version 59.2
All checks were successful
ci / test (push) Successful in 2m20s
ci / build-and-deploy (push) Successful in 36s
- Fix test failure in discgolf app
2026-07-04 10:18:42 -04:00
0deb3ee634 [discgolf] Fix breaking tests 2026-07-04 10:18:17 -04:00
28a53d70eb [release] Bump to version 59.1
Some checks failed
ci / test (push) Failing after 1m50s
ci / build-and-deploy (push) Has been skipped
- Fix bug when expansions have no image
2026-07-04 10:10:22 -04:00
98d4e8bacb [boardgames] Fix bug when expansion has no image 2026-07-04 10:09:55 -04:00
8f97131b8d [release] Bump to version 59.0
All checks were successful
ci / test (push) Successful in 2m16s
ci / build-and-deploy (push) Successful in 48s
- Add BoardGameVariant model
- Lookup all Expansions for a game when creating it
- Board game expansion lookup should be async on URL scrobbles
2026-07-04 09:36:52 -04:00
7c1f709f96 [boardgames] Fix saving variants 2026-07-04 01:50:05 -04:00
4b005e0e5b [boardgames] Lookup expansions async for URL scrobbles
All checks were successful
ci / test (push) Successful in 2m27s
ci / build-and-deploy (push) Has been skipped
2026-07-04 01:47:15 -04:00
d5dd63be0d [boardgames] Add expansion fetching 2026-07-04 01:34:42 -04:00
5aa89b7e0a [boardgames] Add idea of board game variants 2026-07-04 01:16:14 -04:00
cf444e8dd4 [release] Bump to version 58.8
All checks were successful
ci / test (push) Successful in 2m16s
ci / build-and-deploy (push) Successful in 1m7s
- Clean up trend templates
2026-07-01 23:22:54 -04:00
a74a89c747 [trends] Clean up display 2026-07-01 23:22:38 -04:00
1695f7393e [release] Bump to version 58.7
All checks were successful
ci / test (push) Successful in 2m9s
ci / build-and-deploy (push) Successful in 33s
- Split up chart page between tables and maloja
- Fix CI so we don't double run deploys and builds
2026-06-30 16:30:16 -04:00
4468e68110 [charts] Split maloja charts out from tables 2026-06-30 16:29:56 -04:00
da08eca4ab [ci] Fix split in files 2026-06-30 16:26:07 -04:00
08752e30a4 [release] Bump to version 58.6
All checks were successful
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 2m17s
deploy / build-and-deploy (push) Successful in 34s
- Cleanup commands should check for broken images
2026-06-30 16:04:37 -04:00
619718c045 [charts] Fix chart page missing tables 2026-06-30 16:04:09 -04:00
cb23d5a5be [metadata] Fix cleanup scripts to check for dead images 2026-06-30 16:03:29 -04:00
ec4c190e6c [release] Bump to version 58.5
All checks were successful
build / test (push) Successful in 2m15s
deploy / test (push) Successful in 2m14s
deploy / build-and-deploy (push) Successful in 56s
- The maloja style charts are messed up
2026-06-30 15:02:16 -04:00
58126928c7 [charts] Fix maloja charts acting weird
Some checks failed
build / test (push) Has been cancelled
2026-06-30 15:01:57 -04:00
41 changed files with 1829 additions and 592 deletions

View File

@ -1,68 +0,0 @@
name: build
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
VROBBLER_DATABASE_URL: sqlite:///test.db
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache pip/poetry
uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/pypoetry
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-py311-
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Install deps
run: |
cp vrobbler.conf.test vrobbler.conf
poetry install --with test
- name: Pytest with coverage
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
- name: Notify success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler CI success" \
-H "Priority: low" \
-H "Tags: success,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone
- name: Notify failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler CI failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone

View File

@ -1,8 +1,14 @@
name: deploy name: ci
on: on:
push: push:
branches: ["**"]
tags: ["*"] tags: ["*"]
pull_request:
concurrency:
group: ${{ gitea.workflow }}
cancel-in-progress: false
jobs: jobs:
test: test:
@ -68,6 +74,7 @@ jobs:
build-and-deploy: build-and-deploy:
needs: [test] needs: [test]
if: startsWith(gitea.ref, 'refs/tags/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources *** Metadata sources
**** Scraper **** Scraper
* Backlog [0/23] :vrobbler:project:personal: * Backlog [0/24] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** 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:
@ -604,6 +604,93 @@ 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:
** TODO [#A] Update how board game scrobbles work :boardgames:
*** Description
When we scrobble a board game from a BGG URL, instead of going to the media
detail page, we should go to the scrobble detail page, with the Edit Log form
expanded by default.
The Edit log form should have from top to bottom:
- Board/Variant (one or many BoardGameVariant in a multi-select widget)
- People (which should be similar to the Bird widget on BirdLocation and allow setting per user score, win true/false, rank, new true/false, seat_ordrer)
- Expansion ids (which should a multi-select widget of expansions for this game)
- Location (which should be a drop down of BoardGameLocations for this user)
* Version 59.2 [1/1]
** DONE Fix test failure in discgolf app :discgolf:tests:
:PROPERTIES:
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
:END:
* Version 59.1 [1/1]
** DONE Fix bug when expansions have no image :boardgames:bug:
:PROPERTIES:
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
:END:
* Version 59.0 [3/3]
** DONE [#A] Add BoardGameVariant model :boardgames:
:PROPERTIES:
:ID: 0ffb20d5-252f-b13d-473d-5529014602ff
:END:
*** Description
Variants represent unique boards being used per scrobble or scenarios when
playing a game. Scrobbles of a board game may have one or more
boardgame_variant_ids assocaited with their log data, and a variant is created
for one specific board game.
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
:PROPERTIES:
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
:END:
*** Description
We don't want to blow up the BGG API, but if possible with not too
many calls, when we scrobble a board game, in order to allow
populating the "Expansions" multi select, we should fetch any
expansions for the board game when creating it for the first time.
We should also create a managemnt script to update existing board games.
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
:PROPERTIES:
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
:END:
* Version 58.8 [1/1]
** DONE [#B] Clean up trend templates :trends:templates:
:PROPERTIES:
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
:END:
* Version 58.7 [2/2]
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
:PROPERTIES:
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
:END:
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
:PROPERTIES:
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
:END:
* Version 58.6 [1/1]
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
:PROPERTIES:
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
:END:
* Version 58.5 [1/1]
** DONE [#A] The maloja style charts are messed up :templates:charts:
:PROPERTIES:
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
:END:
* Version 58.4 [2/2] * Version 58.4 [2/2]
** DONE [#B] Allow people all trends or individual trends :trends:profiles: ** DONE [#B] Allow people all trends or individual trends :trends:profiles:

View File

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

View File

@ -5,6 +5,7 @@ from boardgames.models import (
BoardGameLocation, BoardGameLocation,
BoardGamePublisher, BoardGamePublisher,
BoardGameDesigner, BoardGameDesigner,
BoardGameVariant,
) )
from scrobbles.admin import ScrobbleInline from scrobbles.admin import ScrobbleInline
@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
ordering = ("-created",) ordering = ("-created",)
@admin.register(BoardGameVariant)
class BoardGameVariantAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"board_game",
"uuid",
)
raw_id_fields = ("board_game",)
search_fields = ("name", "board_game__title")
ordering = ("-created",)
@admin.register(BoardGame) @admin.register(BoardGame)
class BoardGameAdmin(admin.ModelAdmin): class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"

View File

@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__" fields = "__all__"
class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameVariant
fields = "__all__"
class BoardGameSerializer(serializers.HyperlinkedModelSerializer): class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta: class Meta:
model = models.BoardGame model = models.BoardGame

View File

@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
class BoardGameVariantViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameVariant.objects.all().order_by("-created")
serializer_class = serializers.BoardGameVariantSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameViewSet(viewsets.ModelViewSet): class BoardGameViewSet(viewsets.ModelViewSet):
queryset = models.BoardGame.objects.all().order_by("-created") queryset = models.BoardGame.objects.all().order_by("-created")
serializer_class = serializers.BoardGameSerializer serializer_class = serializers.BoardGameSerializer

View File

@ -0,0 +1,63 @@
import logging
from django.core.management.base import BaseCommand
from boardgames.utils import board_names_to_variants
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Convert existing board scrobble log 'board' keys to 'variant_ids'"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
def handle(self, *args, **options):
commit = options.get("commit", False)
board_scrobbles = Scrobble.objects.filter(
board_game__isnull=False,
log__board__isnull=False,
).exclude(log__board="")
total = board_scrobbles.count()
self.stdout.write(f"Found {total} scrobbles with a 'board' key in log data")
if total == 0:
return
updated = 0
for scrobble in board_scrobbles.iterator(chunk_size=100):
log = scrobble.log
board_value = log.pop("board", None)
if not board_value:
continue
variant_ids = board_names_to_variants(
scrobble.board_game, [board_value]
)
if variant_ids:
log["variant_ids"] = variant_ids
if commit:
Scrobble.objects.filter(pk=scrobble.pk).update(log=log)
updated += 1
else:
updated += 1
if commit:
self.stdout.write(
self.style.SUCCESS(
f"Updated {updated} scrobbles (changes committed)"
)
)
else:
self.stdout.write(
f"Would update {updated} scrobbles (pass --commit to persist)"
)

View File

@ -0,0 +1,78 @@
import logging
from django.core.management.base import BaseCommand
from boardgames.models import BoardGame
from boardgames.sources.bgg import lookup_boardgame_from_bgg
from boardgames.utils import fetch_and_link_expansions
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Fetch and link expansions for existing board games from BGG"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
parser.add_argument(
"--bggeek-id",
type=str,
help="Only process a single game by BGG ID",
)
def handle(self, *args, **options):
commit = options.get("commit", False)
bggeek_id = options.get("bggeek_id")
games = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(
bggeek_id=""
)
if bggeek_id:
games = games.filter(bggeek_id=bggeek_id)
total = games.count()
self.stdout.write(f"Found {total} board games with BGG IDs")
if total == 0:
return
updated = 0
for game in games.iterator(chunk_size=100):
try:
data = lookup_boardgame_from_bgg(lookup_id=str(game.bggeek_id))
except Exception as e:
self.stdout.write(
self.style.WARNING(
f"Failed to fetch BGG data for {game.title} ({game.bggeek_id}): {e}"
)
)
continue
expansions = data.get("expansions", [])
if not expansions:
continue
if commit:
fetch_and_link_expansions(game, expansions)
updated += 1
self.stdout.write(
f" Linked {len(expansions)} expansions to {game.title}"
)
else:
self.stdout.write(
f" Would link {len(expansions)} expansions to {game.title}"
)
updated += 1
if commit:
self.stdout.write(
self.style.SUCCESS(f"Updated {updated} games (changes committed)")
)
else:
self.stdout.write(
f"Would update {updated} games (pass --commit to persist)"
)

View File

@ -0,0 +1,62 @@
# Generated by Django 4.2.29 on 2026-07-02 22:23
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0015_alter_boardgame_genre"),
]
operations = [
migrations.CreateModel(
name="BoardGameVariant",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
(
"board_game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="variants",
to="boardgames.boardgame",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -71,11 +71,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
difficulty: Optional[int] = None difficulty: Optional[int] = None
solo: Optional[bool] = None solo: Optional[bool] = None
two_handed: Optional[bool] = None two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None expansion_ids: Optional[list[int]] = None
moves: Optional[list] = None moves: Optional[list] = None
rated: Optional[str] = None rated: Optional[str] = None
speed: Optional[str] = None speed: Optional[str] = None
variant: Optional[str] = None variant: Optional[str] = None
variant_ids: Optional[list[int]] = None
lichess_id: Optional[int] = None lichess_id: Optional[int] = None
board: Optional[str] = None board: Optional[str] = None
rounds: Optional[int] = None rounds: Optional[int] = None
@ -106,10 +107,30 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
required=False, required=False,
widget=forms.Select(), widget=forms.Select(),
), ),
"variant_ids": forms.ModelMultipleChoiceField(
queryset=BoardGameVariant.objects.all(),
required=False,
widget=forms.SelectMultiple(attrs={"size": 5}),
),
"expansion_ids": forms.ModelMultipleChoiceField(
queryset=BoardGame.objects.filter(
expansion_for_boardgame__isnull=False
),
required=False,
widget=forms.SelectMultiple(attrs={"size": 5}),
),
} }
fields.update(custom_fields) fields.update(custom_fields)
return fields return fields
@cached_property
def variants(self) -> list["BoardGameVariant"]:
if not self.variant_ids:
return []
return list(
BoardGameVariant.objects.filter(id__in=self.variant_ids)
)
@cached_property @cached_property
def location(self): def location(self):
if not self.location_id: if not self.location_id:
@ -135,6 +156,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.board: if self.board:
html_parts.append(f'<div class="boardgame-board">{self.board}</div>') html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
if self.variants:
variant_names = ", ".join(v.name for v in self.variants)
html_parts.append(
f'<div class="boardgame-variants">Variants: {variant_names}</div>'
)
if self.location: if self.location:
html_parts.append(f'<div class="boardgame-location">{self.location}</div>') html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
@ -306,18 +333,28 @@ class BoardGame(ScrobblableMixin):
if not data: if not data:
data = lookup_boardgame_from_bgg(str(self.bggeek_id)) data = lookup_boardgame_from_bgg(str(self.bggeek_id))
cover_url = data.pop("cover_url") expansions = data.pop("expansions", [])
year = data.pop("year_published") cover_url = data.pop("cover_url", "")
publisher_name = data.pop("publisher_name") year_published = data.pop("year_published", None)
if year_published is None:
year_published = data.pop("published_year", None)
publisher_name = data.pop("publisher_name", "")
if year: if year_published:
data["published_year"] = int(year) data["published_year"] = int(year_published)
if not data["min_players"]: if not data["min_players"]:
data.pop("min_players") data.pop("min_players")
if not data["min_players"]: if not data["min_players"]:
data.pop("max_players") data.pop("max_players")
# Pop extra BGG metadata that isn't a model field
data.pop("mechanics", None)
data.pop("categories", None)
data.pop("designers", None)
data.pop("publishers", None)
data.pop("publisher", None)
# Fun trick for updating all fields at once # Fun trick for updating all fields at once
BoardGame.objects.filter(pk=self.id).update(**data) BoardGame.objects.filter(pk=self.id).update(**data)
self.refresh_from_db() self.refresh_from_db()
@ -333,6 +370,10 @@ class BoardGame(ScrobblableMixin):
if cover_url and not self.cover: if cover_url and not self.cover:
self.save_image_from_url(cover_url) self.save_image_from_url(cover_url)
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(self, expansions)
def save_image_from_url(self, url): def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"} headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers) r = requests.get(url, headers=headers)
@ -341,7 +382,12 @@ class BoardGame(ScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True) self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod @classmethod
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame": def find_or_create(
cls,
lookup_id: str,
data: dict[str, Any] = {},
defer_expansions: bool = False,
) -> "BoardGame":
"""Given a Lookup ID (either BGG or BGA ID), return a board game object""" """Given a Lookup ID (either BGG or BGA ID), return a board game object"""
game = cls.objects.filter(bggeek_id=lookup_id).first() game = cls.objects.filter(bggeek_id=lookup_id).first()
if not game: if not game:
@ -364,16 +410,18 @@ class BoardGame(ScrobblableMixin):
else: else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id) bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
expansions = bgg_data.pop("expansions", [])
mechanics = bgg_data.pop("mechanics", []) mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", []) designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", []) categories = bgg_data.pop("categories", [])
publishers = bgg_data.pop("publishers", []) publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", []) publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url") cover_url = bgg_data.pop("cover_url") or ""
game = cls.objects.create(**bgg_data) game = cls.objects.create(**bgg_data)
game.save_image_from_url(cover_url) if cover_url:
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False) game.cooperative = data.get("cooperative", False)
game.highest_wins = data.get("highestWins", True) game.highest_wins = data.get("highestWins", True)
game.no_points = data.get("noPoints", False) game.no_points = data.get("noPoints", False)
@ -396,4 +444,27 @@ class BoardGame(ScrobblableMixin):
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name) publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
game.publishers.add(publisher) game.publishers.add(publisher)
if defer_expansions:
from boardgames.tasks import fetch_board_game_expansions
fetch_board_game_expansions.delay(game.id, expansions)
else:
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(game, expansions)
return game return game
class BoardGameVariant(TimeStampedModel):
name = models.CharField(max_length=255)
board_game = models.ForeignKey(
BoardGame,
on_delete=models.CASCADE,
related_name="variants",
)
description = models.TextField(**BNULL)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
def __str__(self) -> str:
return f"{self.name} ({self.board_game.title})"

View File

@ -36,5 +36,8 @@ def lookup_boardgame_from_bgg(
game_dict["publishers"] = game.publishers game_dict["publishers"] = game.publishers
if game.publishers: if game.publishers:
game_dict["publisher"] = game.publishers[0] game_dict["publisher"] = game.publishers[0]
game_dict["expansions"] = [
{"id": exp.id, "name": exp.name} for exp in game.expansions
]
return game_dict return game_dict

View File

@ -0,0 +1,29 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task
def fetch_board_game_expansions(board_game_id, expansions_data):
from boardgames.models import BoardGame
from boardgames.utils import fetch_and_link_expansions
game = BoardGame.objects.filter(id=board_game_id).first()
if not game:
logger.warning(
"Board game not found for expansion linking",
extra={"board_game_id": board_game_id},
)
return
fetch_and_link_expansions(game, expansions_data)
logger.info(
"Linked expansions for board game",
extra={
"board_game_id": board_game_id,
"title": game.title,
"count": len(expansions_data),
},
)

View File

@ -0,0 +1,225 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from boardgames.models import BoardGame, BoardGameVariant
User = get_user_model()
@pytest.mark.django_db
def test_board_game_variant_creation():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
description="A test variant",
)
assert variant.name == "Test Variant"
assert variant.board_game == game
assert variant.description == "A test variant"
assert variant.uuid is not None
@pytest.mark.django_db
def test_board_game_variant_str():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
assert str(variant) == "Test Variant (Test Game)"
@pytest.mark.django_db
def test_board_game_variant_optional_description():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
assert variant.description is None
@pytest.mark.django_db
def test_board_game_variant_related_name():
game = BoardGame.objects.create(title="Test Game")
variant1 = BoardGameVariant.objects.create(
name="Variant 1",
board_game=game,
)
variant2 = BoardGameVariant.objects.create(
name="Variant 2",
board_game=game,
)
assert list(game.variants.all()) == [variant1, variant2]
@pytest.mark.django_db
def test_board_game_variant_cascade_delete():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
game.delete()
assert BoardGameVariant.objects.count() == 0
def _mock_bgg_game(bggeek_id, title, expansions=None):
"""Build a fake BGG game object shape used by lookup_boardgame_from_bgg."""
class FakeGame:
id = bggeek_id
name = title
description = f"Description of {title}"
yearpublished = 2020
image = "https://example.com/cover.jpg"
minplayers = 1
maxplayers = 4
minage = 8
rating_average = 7.5
bgg_rank = 100
playingtime = 60
mechanics = []
categories = []
designers = []
publishers = []
@property
def expansions(self):
if expansions is None:
return []
return expansions
return FakeGame()
def _mock_bgg_client(return_game):
"""Return a callable that creates a fake BGGClient instance."""
class FakeBGGClient:
def __init__(self, access_token=None):
pass
def game(self, game_id=None, name=None):
return return_game
return FakeBGGClient()
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_find_or_create_links_expansions(mock_bgg, mock_get):
exp1 = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
exp2 = type("Thing", (), {"id": 201, "name": "Expansion 2"})()
base_game = _mock_bgg_game("100", "Base Game", expansions=[exp1, exp2])
exp1_game = _mock_bgg_game("200", "Expansion 1")
exp2_game = _mock_bgg_game("201", "Expansion 2")
mock_bgg.side_effect = [
_mock_bgg_client(base_game),
_mock_bgg_client(exp1_game),
_mock_bgg_client(exp2_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
game = BoardGame.find_or_create("100")
assert game.title == "Base Game"
expansions = BoardGame.objects.filter(expansion_for_boardgame=game)
assert expansions.count() == 2
assert {e.title for e in expansions} == {"Expansion 1", "Expansion 2"}
@pytest.mark.django_db
@patch("boardgames.sources.bgg.BGGClient")
def test_find_or_create_skips_expansions_for_existing_game(mock_bgg):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
mock_bgg.assert_not_called()
result = BoardGame.find_or_create("100")
assert result == game
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_fetch_and_link_expansions(mock_bgg, mock_get):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_game = _mock_bgg_game("200", "Expansion 1")
mock_bgg.side_effect = [_mock_bgg_client(exp_game)]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(game, [{"id": 200, "name": "Expansion 1"}])
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_fix_metadata_links_expansions(mock_bgg, mock_get):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
exp_game = _mock_bgg_game("200", "Expansion 1")
# first call = fix_metadata -> lookup_boardgame_from_bgg(lookup_id='100')
# second call = find_or_create inside fetch_and_link_expansions for expansion
mock_bgg.side_effect = [
_mock_bgg_client(base_with_exp),
_mock_bgg_client(exp_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
game.fix_metadata(force_update=True)
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game
@pytest.mark.django_db
@patch("boardgames.sources.bgg.BGGClient")
def test_management_command_fetch_expansions_dry_run(mock_bgg, capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
mock_bgg.side_effect = [_mock_bgg_client(base_with_exp)]
call_command("fetch_expansions")
captured = capsys.readouterr()
assert "Would link 1 expansions" in captured.out
assert BoardGame.objects.filter(bggeek_id="200").count() == 0
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_management_command_fetch_expansions_commit(mock_bgg, mock_get, capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
exp_game = _mock_bgg_game("200", "Expansion 1")
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
mock_bgg.side_effect = [
_mock_bgg_client(base_with_exp),
_mock_bgg_client(exp_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
call_command("fetch_expansions", commit=True)
captured = capsys.readouterr()
assert "Updated 1 games" in captured.out
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game

View File

@ -0,0 +1,106 @@
import pytest
from django.contrib.auth import get_user_model
from boardgames.models import BoardGame, BoardGameVariant
from boardgames.utils import board_names_to_variants
from scrobbles.models import Scrobble
User = get_user_model()
@pytest.mark.django_db
def test_board_names_to_variants_creates_variant():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
variant = BoardGameVariant.objects.get(id=ids[0])
assert variant.name == "Map A"
assert variant.board_game == game
@pytest.mark.django_db
def test_board_names_to_variants_reuses_existing():
game = BoardGame.objects.create(title="Test Game")
existing = BoardGameVariant.objects.create(
name="Map A", board_game=game
)
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
assert ids[0] == existing.id
@pytest.mark.django_db
def test_board_names_to_variants_multiple_names():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A", "Map B"])
assert len(ids) == 2
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
assert names == {"Map A", "Map B"}
@pytest.mark.django_db
def test_board_names_to_variants_splits_fullwidth_slash():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map AMap B"])
assert len(ids) == 2
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
assert names == {"Map A", "Map B"}
@pytest.mark.django_db
def test_board_names_to_variants_skips_empty_parts():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
assert BoardGameVariant.objects.get(id=ids[0]).name == "Map A"
@pytest.mark.django_db
def test_board_names_to_variants_different_games_independent():
game1 = BoardGame.objects.create(title="Game 1")
game2 = BoardGame.objects.create(title="Game 2")
ids1 = board_names_to_variants(game1, ["Map A"])
ids2 = board_names_to_variants(game2, ["Map A"])
assert ids1 != ids2
assert BoardGameVariant.objects.count() == 2
@pytest.mark.django_db
def test_management_command_dry_run(capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Test Game")
user = User.objects.create(username="tester")
scrobble = Scrobble.objects.create(
user=user,
board_game=game,
log={"board": "Map A"},
)
call_command("convert_board_to_variants")
captured = capsys.readouterr()
assert "Would update 1 scrobbles" in captured.out
scrobble.refresh_from_db()
assert "board" in scrobble.log
@pytest.mark.django_db
def test_management_command_commit():
from django.core.management import call_command
game = BoardGame.objects.create(title="Test Game")
user = User.objects.create(username="tester")
scrobble = Scrobble.objects.create(
user=user,
board_game=game,
log={"board": "Map A"},
)
call_command("convert_board_to_variants", commit=True)
scrobble.refresh_from_db()
assert "board" not in scrobble.log
assert "variant_ids" in scrobble.log
variant = BoardGameVariant.objects.get(board_game=game, name="Map A")
assert variant.id in scrobble.log["variant_ids"]

View File

@ -0,0 +1,62 @@
import logging
from typing import Any
from boardgames.models import BoardGame, BoardGameVariant
logger = logging.getLogger(__name__)
def board_names_to_variants(
board_game: BoardGame, board_names: list[str]
) -> list[int]:
"""Given a board game and a list of board/scenario names, find or create
BoardGameVariant records and return their IDs.
Splits each name on the full-width slash ```` so that a single field
containing ``Map AMap B`` produces two separate variants.
"""
variant_ids: list[int] = []
for raw_name in board_names:
for part in raw_name.split(""):
name = part.strip()
if not name:
continue
variant, was_created = BoardGameVariant.objects.get_or_create(
board_game=board_game,
name=name,
)
logger.debug(
"Resolved board variant",
extra={
"board_game_id": board_game.id,
"variant_name": name,
"variant_id": variant.id,
"was_created": was_created,
},
)
variant_ids.append(variant.id)
return variant_ids
def fetch_and_link_expansions(
board_game: BoardGame, expansions_data: list[dict[str, Any]]
) -> None:
"""Given a board game and a list of expansion dicts (with 'id' and 'name'),
find or create each expansion BoardGame and link it via expansion_for_boardgame.
"""
for exp_data in expansions_data:
exp_id = exp_data.get("id")
if not exp_id:
continue
expansion = BoardGame.find_or_create(str(exp_id))
if expansion and expansion.id != board_game.id:
expansion.expansion_for_boardgame = board_game
expansion.save(update_fields=["expansion_for_boardgame"])
logger.info(
"Linked expansion to board game",
extra={
"board_game_id": board_game.id,
"expansion_id": expansion.id,
"expansion_name": expansion.title,
},
)

View File

@ -18,8 +18,17 @@ MISSING_ALL = [
"publish_year", "publish_year",
] ]
def _cover_missing_or_broken(book) -> bool:
if not bool(book.cover):
return True
try:
return not book.cover.storage.exists(book.cover.name)
except Exception:
return True
MISSING_GROUPS = { MISSING_GROUPS = {
"cover": lambda b: not bool(b.cover), "cover": _cover_missing_or_broken,
"summary": lambda b: not b.summary, "summary": lambda b: not b.summary,
"isbn": lambda b: not b.isbn_13 and not b.isbn_10, "isbn": lambda b: not b.isbn_13 and not b.isbn_10,
"pages": lambda b: b.pages is None, "pages": lambda b: b.pages is None,

View File

@ -3,6 +3,7 @@ from charts.views import (
BirdsChartView, BirdsChartView,
ChartDetailView, ChartDetailView,
ChartRecordView, ChartRecordView,
MalojaChartsView,
SpotifyTracksView, SpotifyTracksView,
) )
from django.urls import path from django.urls import path
@ -11,6 +12,7 @@ app_name = "charts"
urlpatterns = [ urlpatterns = [
path("charts/", ChartRecordView.as_view(), name="charts-home"), path("charts/", ChartRecordView.as_view(), name="charts-home"),
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"), path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"), path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"), path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),

View File

@ -114,175 +114,13 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week context["current_week"] = current_week
context["current_day"] = current_day context["current_day"] = current_day
context["chart_keys"] = { # Resolve date parameters
"today": "Today", if date_param:
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"album": {
"today": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "album", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "album", year=current_year)
),
"all": list(self.get_charts_for_period(user, "album")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
if not date_param:
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=current_year, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=current_year, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=current_year, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=current_year, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=current_year, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=current_year, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=current_year, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=current_year, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=current_year, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=current_year, limit=20
)
),
}
else:
parts = date_param.split("-") parts = date_param.split("-")
year = int(parts[0]) year = int(parts[0])
week = None week = None
month = None month = None
day = None day = None
if len(parts) >= 2 and parts[1].startswith("W"): if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W")) week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]: elif len(parts) >= 2 and parts[1]:
@ -290,20 +128,17 @@ class ChartRecordView(TemplateView):
month = int(parts[1]) month = int(parts[1])
except ValueError: except ValueError:
pass pass
if len(parts) >= 3: if len(parts) >= 3:
if parts[2].startswith("W"): if parts[2].startswith("W"):
week = int(parts[2].lstrip("W")) week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"): elif not parts[2].startswith("W"):
day = int(parts[2]) day = int(parts[2])
context["period"] = "historical" context["period"] = "historical"
context["year"] = year context["year"] = year
context["month"] = month context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week context["week"] = week
context["day"] = day context["day"] = day
period_str = str(year) period_str = str(year)
if month: if month:
period_str = f"{calendar.month_name[month]} {period_str}" period_str = f"{calendar.month_name[month]} {period_str}"
@ -312,109 +147,82 @@ class ChartRecordView(TemplateView):
if day: if day:
period_str = f"{calendar.month_name[month]} {day}, {year}" period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str context["period_str"] = period_str
else:
year = current_year
month = current_month
week = current_week
day = current_day
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = { # List-group tables default to week-level when no date param (matches active tab)
"artist": list( if not date_param:
self.get_charts_for_period( list_year = current_year
user, list_month = None
"artist", list_week = current_week
year=year, list_day = None
month=month, else:
week=week, list_year = year
day=day, list_month = month
) list_week = week
), list_day = day
"album": list(
self.get_charts_for_period( context["charts"] = {
user, "artist": list(
"album", self.get_charts_for_period(
year=year, user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "album": list(
) self.get_charts_for_period(
), user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"track": list( )
self.get_charts_for_period( ),
user, "track": list(
"track", self.get_charts_for_period(
year=year, user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "tv_series": list(
) self.get_charts_for_period(
), user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"tv_series": list( )
self.get_charts_for_period( ),
user, "video": list(
"tv_series", self.get_charts_for_period(
year=year, user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "board_game": list(
) self.get_charts_for_period(
), user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"video": list( )
self.get_charts_for_period( ),
user, "book": list(
"video", self.get_charts_for_period(
year=year, user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "food": list(
) self.get_charts_for_period(
), user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"board_game": list( )
self.get_charts_for_period( ),
user, "podcast": list(
"board_game", self.get_charts_for_period(
year=year, user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "trail": list(
) self.get_charts_for_period(
), user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"book": list( )
self.get_charts_for_period( ),
user, }
"book",
year=year,
month=month,
week=week,
day=day,
)
),
"food": list(
self.get_charts_for_period(
user,
"food",
year=year,
month=month,
week=week,
day=day,
)
),
"podcast": list(
self.get_charts_for_period(
user,
"podcast",
year=year,
month=month,
week=week,
day=day,
)
),
"trail": list(
self.get_charts_for_period(
user,
"trail",
year=year,
month=month,
week=week,
day=day,
)
),
}
bird_data = self.get_bird_chart_data( bird_data = self.get_bird_chart_data(
user, user,
@ -628,6 +436,57 @@ class ChartRecordView(TemplateView):
} }
class MalojaChartsView(ChartRecordView):
"""Three maloja-themed image grid widgets (artists, albums, TV series)
with Today/Week/Month/Year/All tabs. Each tab computes its own period
from the current date — no query param needed."""
template_name = "charts/maloja_charts.html"
def get_context_data(self, **kwargs):
context = super(ChartRecordView, self).get_context_data(**kwargs)
user = self.request.user
if not user.is_authenticated:
context["maloja_charts"] = {}
context["chart_keys"] = {}
return context
now = timezone.now()
now = now_user_timezone(user.profile)
today = now.date()
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
tab_params = {
"today": {"year": today.year, "month": today.month, "day": today.day},
"week": {"year": today.year, "week": today.isocalendar()[1]},
"month": {"year": today.year, "month": today.month},
"year": {"year": today.year},
}
maloja_charts = {}
for media_type in ("artist", "album", "tv_series"):
tabs = {}
for key in ("today", "week", "month", "year"):
tabs[key] = list(
self.get_charts_for_period(user, media_type, **tab_params[key])
)
tabs["all"] = list(
self.get_charts_for_period(user, media_type)
)
maloja_charts[media_type] = tabs
context["maloja_charts"] = maloja_charts
return context
MEDIA_TYPE_LABELS = { MEDIA_TYPE_LABELS = {
"artist": ("🎤", "Top Artists"), "artist": ("🎤", "Top Artists"),
"album": ("💿", "Top Albums"), "album": ("💿", "Top Albums"),

View File

@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
def _parse_udisc_datetime(raw: str) -> datetime: def _parse_udisc_datetime(raw: str) -> datetime:
return parse_datetime(raw) dt = parse_datetime(raw)
if timezone.is_naive(dt):
return timezone.make_aware(dt)
return dt
def _resolve_player(name: str, user_id: int) -> Person: def _resolve_player(name: str, user_id: int) -> Person:

View File

@ -0,0 +1,202 @@
import logging
from django.core.management.base import BaseCommand
from django.db import models, transaction
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Enrich artist and album metadata (covers, thumbnails) from MusicBrainz and TheAudioDB"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing cover image and metadata",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
parser.add_argument(
"--artists",
action="store_true",
help="Only process artists",
)
parser.add_argument(
"--albums",
action="store_true",
help="Only process albums",
)
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process items missing metadata or with broken images",
)
def _has_broken_image(self, obj, field_name: str) -> bool:
field = getattr(obj, field_name, None)
if not field or not field.name:
return False
try:
return not field.storage.exists(field.name)
except Exception:
return True
def handle(self, *args, **options):
from music.models import Album, Artist
force = options["force"]
dry_run = options["dry_run"]
only_artists = options["artists"]
only_albums = options["albums"]
needs_metadata = options["needs_metadata"]
if not only_artists and not only_albums:
only_artists = only_albums = True
updated_total = 0
errors_total = 0
if only_artists:
updated_total += self._process_artists(force, dry_run, needs_metadata)
errors_total = 0 # reset per section
if only_albums:
updated_total += self._process_albums(force, dry_run, needs_metadata)
self.stdout.write(
self.style.SUCCESS(f"\nDone! {updated_total} items processed")
)
def _get_artists(self, needs_metadata: bool):
from music.models import Artist
qs = Artist.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(theaudiodb_id__isnull=True)
| models.Q(theaudiodb_id="")
| models.Q(thumbnail__isnull=True)
| models.Q(thumbnail="")
)
broken = []
if needs_metadata:
broken_qs = Artist.objects.exclude(
models.Q(thumbnail__isnull=True) | models.Q(thumbnail=""),
)
for artist in broken_qs.iterator():
if self._has_broken_image(artist, "thumbnail"):
broken.append(artist)
return list(qs) + broken
def _get_albums(self, needs_metadata: bool):
from music.models import Album
qs = Album.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
| models.Q(cover_image="default-image-replace-me")
)
broken = []
if needs_metadata:
broken_qs = Album.objects.exclude(
models.Q(cover_image__isnull=True) | models.Q(cover_image=""),
)
for album in broken_qs.iterator():
if self._has_broken_image(album, "cover_image"):
broken.append(album)
return list(qs) + broken
def _process_artists(self, force, dry_run, needs_metadata):
from music.models import Artist
artists = self._get_artists(needs_metadata) if needs_metadata else list(Artist.objects.all())
total = len(artists)
self.stdout.write(f"Processing {total} artists")
if dry_run:
for artist in artists:
has_tadb = bool(artist.theaudiodb_id)
has_thumb = bool(artist.thumbnail)
thumb_broken = self._has_broken_image(artist, "thumbnail")
status = f"theaudiodb_id={'' if has_tadb else ''}"
if thumb_broken:
status += ", thumbnail=BROKEN"
elif has_thumb:
status += ", thumbnail=✓"
else:
status += ", thumbnail=✗"
self.stdout.write(f" [DRY RUN] Would fix {artist.name} ({status})")
return 0
updated = 0
errors = 0
for artist in artists:
try:
with transaction.atomic():
artist.fix_metadata(
force_update=force or self._has_broken_image(artist, "thumbnail")
)
updated += 1
self.stdout.write(f" [ARTIST {updated}/{total}] {artist.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating artist {artist.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nArtists done! {updated} updated, {errors} errors")
)
return updated
def _process_albums(self, force, dry_run, needs_metadata):
from music.models import Album
albums = self._get_albums(needs_metadata) if needs_metadata else list(Album.objects.all())
total = len(albums)
self.stdout.write(f"Processing {total} albums")
if dry_run:
for album in albums:
has_cover = bool(album.cover_image)
cover_broken = self._has_broken_image(album, "cover_image")
if cover_broken:
status = "cover=BROKEN"
elif has_cover:
status = "cover=✓"
else:
status = "cover=✗"
self.stdout.write(f" [DRY RUN] Would fix {album.name} ({status})")
return 0
updated = 0
errors = 0
for album in albums:
try:
with transaction.atomic():
if self._has_broken_image(album, "cover_image") or force:
album.fetch_artwork(force=True)
else:
album.fix_metadata()
updated += 1
self.stdout.write(f" [ALBUM {updated}/{total}] {album.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating album {album.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nAlbums done! {updated} updated, {errors} errors")
)
return updated

View File

@ -8,6 +8,7 @@ import pytz
import requests import requests
from beers.models import Beer from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from boardgames.utils import board_names_to_variants
from books.constants import READCOMICSONLINE_URL from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData, Paper from books.models import Book, BookLogData, BookPageLogData, Paper
from books.utils import parse_readcomicsonline_uri from books.utils import parse_readcomicsonline_uri
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
source: str = "BGG", source: str = "BGG",
action: Optional[str] = None, action: Optional[str] = None,
) -> Scrobble | None: ) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id) boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
if not boardgame: if not boardgame:
logger.error(f"No board game found for ID {bggeek_id}") logger.error(f"No board game found for ID {bggeek_id}")
@ -495,7 +496,9 @@ def email_scrobble_board_game(
if play_dict.get("rounds", False): if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds") log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False): if play_dict.get("board", False):
log_data["board"] = play_dict.get("board") log_data["variant_ids"] = board_names_to_variants(
base_game, [play_dict.get("board")]
)
log_data["players"] = [] log_data["players"] = []
for score_dict in play_dict.get("playerScores", []): for score_dict in play_dict.get("playerScores", []):

View File

@ -1235,6 +1235,15 @@ class ScrobbleDetailView(DetailView):
def get_form_class(self): def get_form_class(self):
return self.object.media_obj.logdata_cls().form() return self.object.media_obj.logdata_cls().form()
def _update_expansion_ids_queryset(self, form):
from boardgames.models import BoardGame
if isinstance(self.object.media_obj, BoardGame) and "expansion_ids" in form.fields:
expansions = BoardGame.objects.filter(
expansion_for_boardgame=self.object.media_obj
)
form.fields["expansion_ids"].queryset = expansions
def get_form(self): def get_form(self):
FormClass = self.get_form_class() FormClass = self.get_form_class()
@ -1245,12 +1254,15 @@ class ScrobbleDetailView(DetailView):
else: else:
log["notes"] = self.object.logdata.notes_as_str(separator="\n") log["notes"] = self.object.logdata.notes_as_str(separator="\n")
return FormClass(initial=log) form = FormClass(initial=log)
self._update_expansion_ids_queryset(form)
return form
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
FormClass = self.get_form_class() FormClass = self.get_form_class()
form = FormClass(request.POST) form = FormClass(request.POST)
self._update_expansion_ids_queryset(form)
if form.is_valid(): if form.is_valid():
data = form.cleaned_data.copy() data = form.cleaned_data.copy()
@ -1263,6 +1275,12 @@ class ScrobbleDetailView(DetailView):
if data.get("with_people_ids") is not None: if data.get("with_people_ids") is not None:
data["with_people_ids"] = [p.id for p in data["with_people_ids"]] data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
if data.get("expansion_ids") is not None:
data["expansion_ids"] = [e.id for e in data["expansion_ids"]]
if data.get("variant_ids") is not None:
data["variant_ids"] = [v.id for v in data["variant_ids"]]
if data.get("mood_reason_ids") is not None: if data.get("mood_reason_ids") is not None:
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]] data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]

View File

@ -1,45 +1,78 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.distribution %} {% if data.distribution %}
{{ data.distribution|json_script:"activity-distribution-data" }}
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong> Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
</p> </p>
<div class="table-responsive"> <canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
<table class="table table-striped table-sm"> <script>
<thead> (function() {
<tr> var el = document.getElementById('activity-distribution-data');
<th>Media Type</th> if (!el) return;
<th class="text-end">Total</th> var data = JSON.parse(el.textContent);
<th class="text-end">Completed</th> if (!data.length) return;
<th class="text-end">%</th> var canvas = document.getElementById('activityDistChart');
<th>Distribution</th> var ctx = canvas.getContext('2d');
</tr> var W = canvas.width;
</thead> var rowH = 34;
<tbody> var labelW = 140;
{% with max=data.distribution.0.count %} var barLeft = labelW + 8;
{% for entry in data.distribution %} var barRight = W - 80;
<tr> var barMaxW = barRight - barLeft;
<td>{{ entry.media_type }}</td> var padTop = 8;
<td class="text-end">{{ entry.count }}</td>
<td class="text-end">{{ entry.completed }}</td> var maxCount = data[0].count;
<td class="text-end">{{ entry.pct }}%</td>
<td style="width: 30%;"> // Set canvas height based on data
{% if max > 0 %} canvas.height = data.length * rowH + padTop;
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar" function roundRect(x, y, w, h, r) {
style="width: {{ entry.pct }}%;" ctx.beginPath();
aria-valuenow="{{ entry.count }}" ctx.moveTo(x + r, y);
aria-valuemin="0" aria-valuemax="{{ max }}"> ctx.lineTo(x + w - r, y);
</div> ctx.quadraticCurveTo(x + w, y, x + w, y + r);
</div> ctx.lineTo(x + w, y + h - r);
{% endif %} ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
</td> ctx.lineTo(x + r, y + h);
</tr> ctx.quadraticCurveTo(x, y + h, x, y + h - r);
{% endfor %} ctx.lineTo(x, y + r);
{% endwith %} ctx.quadraticCurveTo(x, y, x + r, y);
</tbody> ctx.closePath();
</table> }
</div>
data.forEach(function(entry, i) {
var y = padTop + i * rowH;
var pct = entry.count / maxCount;
var barW = pct * barMaxW;
// Label
ctx.fillStyle = '#374151';
ctx.font = '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(entry.media_type, labelW - 6, y + rowH / 2);
// Bar background
ctx.fillStyle = '#f3f4f6';
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
ctx.fill();
// Bar fill — green (top) to red (bottom)
var hue = 120 * (1 - i / (data.length - 1 || 1));
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
ctx.fill();
// Count + percentage label on the right
ctx.fillStyle = '#374151';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(entry.count + ' (' + entry.pct + '%)', barRight + 6, y + rowH / 2);
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No activity data found.</p> <p class="text-muted">No activity data found.</p>
{% endif %} {% endif %}

View File

@ -1,43 +1,74 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.moods %} {% if data.moods %}
{{ data.moods|json_script:"mood-distribution-data" }}
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong> Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
&middot; Positive: <strong>{{ data.positive_count }}</strong> &middot; Positive: <strong>{{ data.positive_count }}</strong>
&middot; Negative: <strong>{{ data.negative_count }}</strong> &middot; Negative: <strong>{{ data.negative_count }}</strong>
</p> </p>
<div class="table-responsive"> <canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
<table class="table table-striped table-sm"> <script>
<thead> (function() {
<tr> var el = document.getElementById('mood-distribution-data');
<th>Mood</th> if (!el) return;
<th class="text-end">Count</th> var data = JSON.parse(el.textContent);
<th>Distribution</th> if (!data.length) return;
</tr> var canvas = document.getElementById('moodDistChart');
</thead> var ctx = canvas.getContext('2d');
<tbody> var rowH = 34;
{% with max=data.moods.0.count %} var labelW = 140;
{% for entry in data.moods %} var barLeft = labelW + 8;
<tr> var barRight = canvas.width - 80;
<td>{{ entry.mood }}</td> var barMaxW = barRight - barLeft;
<td class="text-end">{{ entry.count }}</td> var padTop = 8;
<td style="width: 40%;">
{% if max > 0 %} canvas.height = data.length * rowH + padTop;
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar" var maxCount = data[0].count;
style="width: {% widthratio entry.count max 100 %}%;"
aria-valuenow="{{ entry.count }}" function roundRect(x, y, w, h, r) {
aria-valuemin="0" aria-valuemax="{{ max }}"> ctx.beginPath();
</div> ctx.moveTo(x + r, y);
</div> ctx.lineTo(x + w - r, y);
{% endif %} ctx.quadraticCurveTo(x + w, y, x + w, y + r);
</td> ctx.lineTo(x + w, y + h - r);
</tr> ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
{% endfor %} ctx.lineTo(x + r, y + h);
{% endwith %} ctx.quadraticCurveTo(x, y + h, x, y + h - r);
</tbody> ctx.lineTo(x, y + r);
</table> ctx.quadraticCurveTo(x, y, x + r, y);
</div> ctx.closePath();
}
data.forEach(function(entry, i) {
var y = padTop + i * rowH;
var pct = entry.count / maxCount;
var barW = pct * barMaxW;
ctx.fillStyle = '#374151';
ctx.font = '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(entry.mood, labelW - 6, y + rowH / 2);
ctx.fillStyle = '#f3f4f6';
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
ctx.fill();
var hue = 120 * (1 - i / (data.length - 1 || 1));
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
ctx.fill();
ctx.fillStyle = '#374151';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('' + entry.count, barRight + 6, y + rowH / 2);
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No mood distribution data found.</p> <p class="text-muted">No mood distribution data found.</p>
{% endif %} {% endif %}

View File

@ -1,37 +1,105 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.trajectory %} {% if data.trajectory %}
<div class="table-responsive"> {{ data.trajectory|json_script:"mood-trajectory-data" }}
<table class="table table-striped table-sm"> <div class="d-flex flex-column align-items-center">
<thead> <canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
<tr> <div class="d-flex gap-4 mt-2 small text-muted">
<th>Date</th> <span>← Earlier</span>
<th class="text-end">Avg Quality</th> <span>Later →</span>
<th class="text-end">Check-ins</th> </div>
<th>Mood Bar</th>
</tr>
</thead>
<tbody>
{% for entry in data.trajectory %}
<tr>
<td>{{ entry.date }}</td>
<td class="text-end">{{ entry.avg_quality }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
<div class="progress" style="height: 16px;">
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
aria-valuenow="{{ entry.avg_quality }}"
aria-valuemin="1" aria-valuemax="7">
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<script>
(function() {
var el = document.getElementById('mood-trajectory-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('moodTrajectoryChart');
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
var pad = { top: 20, right: 20, bottom: 30, left: 40 };
var plotW = W - pad.left - pad.right;
var plotH = H - pad.top - pad.bottom;
var yMin = 1, yMax = 7;
var yRange = yMax - yMin;
var maxCount = data.reduce(function(m, d) { return Math.max(m, d.count); }, 0);
function xPos(i) {
return pad.left + (i / (data.length - 1 || 1)) * plotW;
}
function yPos(val) {
return pad.top + (1 - (val - yMin) / yRange) * plotH;
}
// Background grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 0.5;
for (var q = 1; q <= 7; q++) {
var y = yPos(q);
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(W - pad.right, y);
ctx.stroke();
ctx.fillStyle = '#9ca3af';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(q.toFixed(1), pad.left - 5, y + 4);
}
// Reference line at neutral (4)
var neutralY = yPos(4);
ctx.strokeStyle = '#d1d5db';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(pad.left, neutralY);
ctx.lineTo(W - pad.right, neutralY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#9ca3af';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('neutral', W - pad.right + 4, neutralY + 4);
// Line chart
ctx.beginPath();
data.forEach(function(d, i) {
var x = xPos(i);
var y = yPos(d.avg_quality);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.stroke();
// Gradient fill below the line
var gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom);
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.25)');
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
ctx.lineTo(xPos(data.length - 1), yPos(yMin));
ctx.lineTo(xPos(0), yPos(yMin));
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Dots
data.forEach(function(d, i) {
var x = xPos(i);
var y = yPos(d.avg_quality);
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No mood check-in data found.</p> <p class="text-muted">No mood check-in data found.</p>
{% endif %} {% endif %}

View File

@ -1,50 +1,90 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.hours %} {% if data.hours %}
<div class="table-responsive"> {{ data.hours|json_script:"peak-hours-data" }}
<table class="table table-striped table-sm"> <div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
<thead> <div>
<tr> <canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
<th>Hour</th> </div>
<th class="text-end">Scrobbles</th> <div class="table-responsive" style="max-height:360px; overflow-y:auto;">
<th>Distribution</th> <table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
</tr> <thead>
</thead> <tr>
<tbody> <th style="width:14px; padding-right:0;"></th>
{% with total=data.hours|dictsortreversed:"count"|first %} <th>Hour</th>
{% with max_count=total.count %} <th class="text-end">Scrobbles</th>
{% for entry in data.hours %} </tr>
<tr> </thead>
<td> <tbody>
{% if entry.hour == 0 %} {% for entry in data.hours %}
12 AM <tr>
{% elif entry.hour < 12 %} <td class="p-1 text-center">
{{ entry.hour }} AM <span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
{% elif entry.hour == 12 %} </td>
12 PM <td>
{% else %} {% if entry.hour == 0 %}
{{ entry.hour|add:"-12" }} PM 12 AM
{% endif %} {% elif entry.hour < 12 %}
</td> {{ entry.hour }} AM
<td class="text-end">{{ entry.count }}</td> {% elif entry.hour == 12 %}
<td style="width: 40%;"> 12 PM
{% if max_count > 0 %} {% else %}
<div class="progress" style="height: 12px;"> {{ entry.hour|add:"-12" }} PM
<div class="progress-bar" role="progressbar" {% endif %}
style="width: {{ entry.count|floatformat:0 }}%;" </td>
aria-valuenow="{{ entry.count }}" <td class="text-end">{{ entry.count }}</td>
aria-valuemin="0" aria-valuemax="{{ max_count }}"> </tr>
</div> {% endfor %}
</div> </tbody>
{% endif %} </table>
</td> </div>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div> </div>
<script>
(function() {
var dataEl = document.getElementById('peak-hours-data');
if (!dataEl) return;
var data = JSON.parse(dataEl.textContent);
var total = data.reduce(function(s, h) { return s + h.count; }, 0) || 1;
var canvas = document.getElementById('peakHoursChart');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var cx = canvas.width / 2;
var cy = canvas.height / 2;
var radius = Math.min(cx, cy) - 10;
var startAngle = -Math.PI / 2;
var minCount = data.reduce(function(m, h) { return Math.min(m, h.count); }, Infinity);
var maxCount = data.reduce(function(m, h) { return Math.max(m, h.count); }, 0);
var range = maxCount - minCount || 1;
function countToHue(count) {
var t = (count - minCount) / range;
return 120 * t;
}
data.forEach(function(entry, i) {
var sliceAngle = (entry.count / total) * 2 * Math.PI;
var hue = countToHue(entry.count);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ', 70%, 50%)';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
startAngle += sliceAngle;
});
document.querySelectorAll('.legend-swatch').forEach(function(el) {
var idx = parseInt(el.getAttribute('data-idx'), 10);
var entry = data[idx];
var hue = countToHue(entry.count);
el.style.backgroundColor = 'hsl(' + hue + ', 70%, 50%)';
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No activity data found.</p> <p class="text-muted">No activity data found.</p>
{% endif %} {% endif %}

View File

@ -1,31 +1,45 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.total and data.total > 0 %} {% if data.total and data.total > 0 %}
<h5>Overall</h5> <div class="mb-4">
<div class="table-responsive mb-4"> {% for slug, info in data.categories.items %}
<table class="table table-striped table-sm"> {% if forloop.first %}
<thead> <div class="card text-center border-0 bg-light mb-3">
<tr> <div class="card-body py-4">
<th>Category</th> {% if slug == "early_bird" %}
<th class="text-end">Scrobbles</th> <div class="display-1">🌅</div>
<th class="text-end">%</th> {% elif slug == "day_jay" %}
</tr> <div class="display-1">☀️</div>
</thead> {% else %}
<tbody> <div class="display-1">🌙</div>
{% for slug, info in data.categories.items %} {% endif %}
<tr> <h3 class="mt-2">{{ info.label }}</h3>
<td>{{ info.label }}</td> <div class="display-4 fw-bold">{{ info.pct }}%</div>
<td class="text-end">{{ info.count }}</td> <div class="text-muted">{{ info.count }} scrobbles</div>
<td class="text-end">{{ info.pct }}%</td> </div>
</tr> </div>
{% endfor %} {% else %}
<tr class="table-secondary"> <div class="card text-center border-0 bg-light mb-2">
<td><strong>Total</strong></td> <div class="card-body py-2 d-flex align-items-center justify-content-between px-4">
<td class="text-end"><strong>{{ data.total }}</strong></td> <div class="d-flex align-items-center gap-3">
<td class="text-end"></td> {% if slug == "early_bird" %}
</tr> <span style="font-size:1.5rem;">🌅</span>
</tbody> {% elif slug == "day_jay" %}
</table> <span style="font-size:1.5rem;">☀️</span>
{% else %}
<span style="font-size:1.5rem;">🌙</span>
{% endif %}
<h5 class="mb-0">{{ info.label }}</h5>
</div>
<div class="text-end">
<span class="fs-4 fw-bold">{{ info.pct }}%</span>
<br><small class="text-muted">{{ info.count }} scrobbles</small>
</div>
</div>
</div>
{% endif %}
{% endfor %}
<div class="text-center text-muted small mt-2">Total: {{ data.total }} scrobbles across Books, Trails, Birding Locations, and Board Games</div>
</div> </div>
<h5>By Media Type</h5> <h5>By Media Type</h5>

View File

@ -1,38 +1,27 @@
<div class="row"> <div class="row g-3">
<div class="col-12"> {% if data %}
{% if data %} {% for mt, info in data.items %}
<div class="table-responsive"> <div class="col-6 col-md-4 col-lg-3 col-xl-2">
<table class="table table-striped table-sm"> <div class="card text-center h-100">
<thead> <div class="card-body d-flex flex-column align-items-center justify-content-center" style="aspect-ratio: 1;">
<tr> {% if info.change_pct > 0 %}
<th>Media Type</th> <div class="display-3 lh-1 text-success"></div>
<th class="text-end">Recent ({{ current_period_label }})</th> <div class="fs-6 text-success mt-1">+{{ info.change_pct }}%</div>
<th class="text-end">Previous ({{ current_period_label }})</th> {% elif info.change_pct < 0 %}
<th class="text-end">Change</th> <div class="display-3 lh-1 text-danger"></div>
</tr> <div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>
</thead> {% else %}
<tbody> <div class="display-3 lh-1 text-muted"></div>
{% for mt, info in data.items %} <div class="fs-6 text-muted mt-1">0%</div>
<tr> {% endif %}
<td>{{ mt }}</td> <div class="fw-bold mt-2">{{ mt }}</div>
<td class="text-end">{{ info.recent }}</td> </div>
<td class="text-end">{{ info.previous }}</td> </div>
<td class="text-end">
{% if info.change_pct > 0 %}
<span class="text-success">+{{ info.change_pct }}%</span>
{% elif info.change_pct < 0 %}
<span class="text-danger">{{ info.change_pct }}%</span>
{% else %}
<span class="text-muted">0%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% endfor %}
<p class="text-muted">No trending data found.</p> {% else %}
{% endif %} <div class="col-12">
</div> <p class="text-muted">No trending data found.</p>
</div>
{% endif %}
</div> </div>

View File

@ -59,18 +59,21 @@ def compute_time_of_day_categories(user, period="last_30"):
if slug: if slug:
cat_counts[slug] += count cat_counts[slug] += count
mt_total += count mt_total += count
by_media_type[mt] = { mt_categories = {}
"total": mt_total,
"categories": {},
}
for slug in CATEGORIES: for slug in CATEGORIES:
c = cat_counts[slug] c = cat_counts[slug]
by_media_type[mt]["categories"][slug] = { mt_categories[slug] = {
"count": c, "count": c,
"pct": round((c / mt_total * 100), 1) if mt_total else 0, "pct": round((c / mt_total * 100), 1) if mt_total else 0,
"label": CATEGORIES[slug]["label"], "label": CATEGORIES[slug]["label"],
} }
grand_totals[slug] += c grand_totals[slug] += c
by_media_type[mt] = {
"total": mt_total,
"categories": dict(
sorted(mt_categories.items(), key=lambda x: x[1]["count"], reverse=True)
),
}
grand_total += mt_total grand_total += mt_total
categories = {} categories = {}
@ -81,6 +84,9 @@ def compute_time_of_day_categories(user, period="last_30"):
"pct": round((c / grand_total * 100), 1) if grand_total else 0, "pct": round((c / grand_total * 100), 1) if grand_total else 0,
"label": CATEGORIES[slug]["label"], "label": CATEGORIES[slug]["label"],
} }
categories = dict(
sorted(categories.items(), key=lambda x: x[1]["count"], reverse=True)
)
return { return {
"categories": categories, "categories": categories,

View File

@ -17,8 +17,8 @@ MISSING_ALL = [
] ]
MISSING_GROUPS = { MISSING_GROUPS = {
"cover": lambda g: not bool(g.cover), "cover": lambda g: _image_missing_or_broken(g, "cover"),
"screenshot": lambda g: not bool(g.screenshot), "screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
"summary": lambda g: not g.summary, "summary": lambda g: not g.summary,
"rating": lambda g: g.rating is None, "rating": lambda g: g.rating is None,
"release_date": lambda g: g.release_date is None, "release_date": lambda g: g.release_date is None,
@ -28,6 +28,16 @@ MISSING_GROUPS = {
} }
def _image_missing_or_broken(game, field_name) -> bool:
field = getattr(game, field_name)
if not bool(field):
return True
try:
return not field.storage.exists(field.name)
except Exception:
return True
def _game_matches(game, flags): def _game_matches(game, flags):
if not flags: if not flags:
return False return False
@ -103,6 +113,7 @@ class Command(BaseCommand):
if all_missing: if all_missing:
flags = MISSING_ALL flags = MISSING_ALL
fix_broken_images = True
if not flags and not game_id and not force and not fix_broken_images: if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write( self.stdout.write(

View File

@ -30,6 +30,19 @@ class Command(BaseCommand):
action="store_true", action="store_true",
help="Only process channels with a twitch_id", help="Only process channels with a twitch_id",
) )
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process channels missing youtube_id, twitch_id, cover image, or with broken cover image",
)
def _has_broken_image(self, channel) -> bool:
if not channel.cover_image or not channel.cover_image.name:
return False
try:
return not channel.cover_image.storage.exists(channel.cover_image.name)
except Exception:
return True
def handle(self, *args, **options): def handle(self, *args, **options):
from videos.models import Channel from videos.models import Channel
@ -38,6 +51,7 @@ class Command(BaseCommand):
dry_run = options["dry_run"] dry_run = options["dry_run"]
youtube_only = options["youtube_only"] youtube_only = options["youtube_only"]
twitch_only = options["twitch_only"] twitch_only = options["twitch_only"]
needs_metadata = options["needs_metadata"]
qs = Channel.objects.all() qs = Channel.objects.all()
@ -45,29 +59,61 @@ class Command(BaseCommand):
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="") qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only: elif twitch_only:
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="") qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
elif needs_metadata:
no_id = models.Q(youtube_id__isnull=True) & models.Q(twitch_id__isnull=True)
no_id |= models.Q(youtube_id="") & models.Q(twitch_id="")
no_id |= models.Q(youtube_id__isnull=True) & models.Q(twitch_id="")
no_id |= models.Q(youtube_id="") & models.Q(twitch_id__isnull=True)
qs = qs.filter(
no_id
| models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
)
else: else:
qs = qs.filter( qs = qs.filter(
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False) models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
).exclude(youtube_id="", twitch_id="") ).exclude(youtube_id="", twitch_id="")
total = qs.count() total = qs.count()
self.stdout.write(f"Processing {total} channels") self.stdout.write(f"Processing {total} channels from DB filter")
broken_channels = []
if needs_metadata:
broken_qs = Channel.objects.filter(
cover_image__isnull=False,
).exclude(
cover_image="",
)
if youtube_only:
broken_qs = broken_qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
broken_qs = broken_qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
for channel in broken_qs.iterator():
if self._has_broken_image(channel):
broken_channels.append(channel)
all_channels = list(qs) + broken_channels
total = len(all_channels)
self.stdout.write(f"Total channels to process: {total}")
if dry_run: if dry_run:
for channel in qs.iterator(): for channel in all_channels:
source = "youtube" if channel.youtube_id else "twitch" source = "youtube" if channel.youtube_id else "twitch"
identifier = channel.youtube_id or channel.twitch_id identifier = channel.youtube_id or channel.twitch_id
status = f"({source}: {identifier})"
if self._has_broken_image(channel):
status += " [image BROKEN]"
self.stdout.write( self.stdout.write(
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})" f" [DRY RUN] Would fix {channel.name} {status}"
) )
return return
updated = 0 updated = 0
errors = 0 errors = 0
for channel in qs.iterator(): for channel in all_channels:
try: try:
with transaction.atomic(): with transaction.atomic():
channel.fix_metadata(force=force) channel.fix_metadata(force=force or self._has_broken_image(channel))
updated += 1 updated += 1
source = "youtube" if channel.youtube_id else "twitch" source = "youtube" if channel.youtube_id else "twitch"
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})") self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")

View File

@ -1,4 +1,5 @@
import logging import logging
import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import models, transaction from django.db import models, transaction
@ -28,9 +29,18 @@ class Command(BaseCommand):
parser.add_argument( parser.add_argument(
"--needs-metadata", "--needs-metadata",
action="store_true", action="store_true",
help="Only process series missing imdb_id or cover image", help="Only process series missing imdb_id or with broken cover image",
) )
def _has_broken_image(self, series) -> bool:
"""Check if a series has a cover_image set but the file is missing."""
if not series.cover_image:
return False
try:
return not os.path.exists(series.cover_image.path)
except Exception:
return True
def handle(self, *args, **options): def handle(self, *args, **options):
from videos.models import Series from videos.models import Series
@ -51,25 +61,50 @@ class Command(BaseCommand):
) )
total = qs.count() total = qs.count()
self.stdout.write(f"Processing {total} series") self.stdout.write(f"Processing {total} series from DB filter")
# Also find series with broken cover images
broken_image_series = []
if needs_metadata:
broken_qs = Series.objects.filter(
cover_image__isnull=False,
).exclude(
models.Q(imdb_id__isnull=True)
| models.Q(imdb_id="")
| models.Q(cover_image__isnull=True)
| models.Q(cover_image=""),
)
if imdb_id:
broken_qs = broken_qs.filter(imdb_id=imdb_id)
for series in broken_qs.iterator():
if self._has_broken_image(series):
broken_image_series.append(series)
all_series = list(qs) + broken_image_series
total = len(all_series)
self.stdout.write(f"Total series to process: {total}")
if dry_run: if dry_run:
for series in qs.iterator(): for series in all_series:
has_imdb = bool(series.imdb_id) has_imdb = bool(series.imdb_id)
has_image = bool(series.cover_image) has_image = bool(series.cover_image)
self.stdout.write( image_broken = self._has_broken_image(series)
f" [DRY RUN] Would fix {series.name}" status = f"imdb_id={'' if has_imdb else ''}"
f" (imdb_id={'' if has_imdb else ''}" if image_broken:
f", image={'' if has_image else ''})" status += ", image=BROKEN"
) elif has_image:
status += ", image=✓"
else:
status += ", image=✗"
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
return return
updated = 0 updated = 0
errors = 0 errors = 0
for series in qs.iterator(): for series in all_series:
try: try:
with transaction.atomic(): with transaction.atomic():
series.fix_metadata(force_update=force) series.fix_metadata(force_update=force or self._has_broken_image(series))
updated += 1 updated += 1
self.stdout.write(f" [{updated}/{total}] {series.name}") self.stdout.write(f" [{updated}/{total}] {series.name}")
except Exception as e: except Exception as e:

View File

@ -103,7 +103,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York") TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")] CSRF_TRUSTED_ORIGINS = [
os.getenv(
"VROBBLER_TRUSTED_ORIGINS",
"http://localhost:8000",
),
"https://dev-vrobbler.lab.unbl.ink",
]
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None) REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)

View File

@ -120,9 +120,67 @@
</div> </div>
{% endif %} {% endif %}
{% include "scrobbles/_top_charts.html" %} <div class="row mb-3">
<div class="col-12">
<a href="{% url 'charts:maloja-charts' %}" class="btn btn-sm btn-outline-secondary">🎨 Maloja Widgets</a>
</div>
</div>
<div class="row mt-4">
{% if charts.artist %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎤 Top Artists</h3>
<ul class="list-group">
{% for chart in charts.artist|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'artist' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>💿 Top Albums</h3>
<ul class="list-group">
{% for chart in charts.album|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'album' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📺 Top TV Series</h3>
<ul class="list-group">
{% for chart in charts.tv_series|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
<div class="row">
{% if charts.track %} {% if charts.track %}
<div class="col-md-6 col-lg-4 chart-section"> <div class="col-md-6 col-lg-4 chart-section">
<h3>🎵 Top Tracks</h3> <h3>🎵 Top Tracks</h3>

View File

@ -0,0 +1,45 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Maloja Widgets{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom: 100px; }
h2 { padding-top: 20px; }
.nav-tabs { cursor: pointer; }
.image-wrapper { contain: content; }
.image-wrapper :hover { background: rgba(0,0,0,0.3); }
.caption {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 90%;
color: white; background: rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 75%;
color: white; background: rgba(0,0,0,0.4);
}
.caption-small {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 60%;
color: white; background: rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
{% block grid_view_button %}{% endblock %}
<div class="row mb-3">
<div class="col-12">
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">&larr; Full Charts</a>
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
</div>
</div>
{% include "scrobbles/_top_charts.html" %}
{% endblock %}

View File

@ -49,11 +49,12 @@
</div> </div>
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %} {% for i in "123456789" %}
{% with artists|get_item:forloop.counter|add:5 as artist %} {% with forloop.counter|add:4 as idx %}
{% with artists|get_item:idx as artist %}
{% if artist %} {% if artist %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{artist.artist.name}}</div> <div class="caption-small">#{{forloop.counter|add:5}} {{artist.artist.name}}</div>
{% if artist.artist.thumbnail %} {% if artist.artist.thumbnail %}
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a> <a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a>
{% else %} {% else %}
@ -62,6 +63,7 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -95,7 +97,7 @@
<div style="display:block"> <div style="display:block">
<div style="float:left;"> <div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0"> <div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{albums.0.album.title}}</div> <div class="caption">#1 {{albums.0.album.name}}</div>
{% if albums.0.album.cover_image %} {% if albums.0.album.cover_image %}
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a> <a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
{% else %} {% else %}
@ -109,7 +111,7 @@
{% with albums|get_item:forloop.counter as album %} {% with albums|get_item:forloop.counter as album %}
{% if album %} {% if album %}
<div class="image-wrapper" style="width:50%"> <div class="image-wrapper" style="width:50%">
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div> <div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.name}}</div>
{% if album.album.cover_image %} {% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a> <a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a>
{% else %} {% else %}
@ -123,11 +125,12 @@
</div> </div>
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %} {% for i in "123456789" %}
{% with albums|get_item:forloop.counter|add:5 as album %} {% with forloop.counter|add:4 as idx %}
{% with albums|get_item:idx as album %}
{% if album %} {% if album %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div> <div class="caption-small">#{{forloop.counter|add:5}} {{album.album.name}}</div>
{% if album.album.cover_image %} {% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a> <a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
{% else %} {% else %}
@ -136,6 +139,7 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -197,11 +201,12 @@
</div> </div>
<div style="float:left; width:300px;"> <div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;"> <div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %} {% for i in "123456789" %}
{% with shows|get_item:forloop.counter|add:5 as show %} {% with forloop.counter|add:4 as idx %}
{% with shows|get_item:idx as show %}
{% if show %} {% if show %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{show.tv_series.name}}</div> <div class="caption-small">#{{forloop.counter|add:5}} {{show.tv_series.name}}</div>
{% if show.tv_series.cover_image %} {% if show.tv_series.cover_image %}
<a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a> <a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %} {% else %}
@ -210,6 +215,7 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@ from vrobbler.apps.boardgames.api.views import (
BoardGameDesignerViewSet, BoardGameDesignerViewSet,
BoardGamePublisherViewSet, BoardGamePublisherViewSet,
BoardGameLocationViewSet, BoardGameLocationViewSet,
BoardGameVariantViewSet,
) )
from vrobbler.apps.books import urls as book_urls from vrobbler.apps.books import urls as book_urls
@ -146,6 +147,7 @@ router.register(r"boardgames", BoardGameViewSet)
router.register(r"boardgame-designers", BoardGameDesignerViewSet) router.register(r"boardgame-designers", BoardGameDesignerViewSet)
router.register(r"boardgame-publishers", BoardGamePublisherViewSet) router.register(r"boardgame-publishers", BoardGamePublisherViewSet)
router.register(r"boardgame-locations", BoardGameLocationViewSet) router.register(r"boardgame-locations", BoardGameLocationViewSet)
router.register(r"boardgame-variants", BoardGameVariantViewSet)
router.register(r"podcast-producers", ProducerViewSet) router.register(r"podcast-producers", ProducerViewSet)
router.register(r"podcast-episodes", PodcastEpisodeViewSet) router.register(r"podcast-episodes", PodcastEpisodeViewSet)
router.register(r"podcasts", PodcastViewSet) router.register(r"podcasts", PodcastViewSet)