Merge branch 'dev' into feature/progress_line

This commit is contained in:
Buslig Gábor
2026-02-03 20:48:37 +01:00
65 changed files with 903 additions and 227 deletions

15
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,15 @@
repos:
- repo: local
hooks:
- id: ruff
name: ruff
entry: ruff check --fix
language: system
types: [python]
require_serial: true
- id: ruff-format
name: ruff-format
entry: ruff format
language: system
types: [python]
require_serial: true

View File

@@ -6,7 +6,7 @@
![Codecov](https://codecov.io/github/FuzzyGrim/Yamtrack/branch/dev/graph/badge.svg?token=PWUG660120)
![GitHub](https://img.shields.io/badge/license-AGPL--3.0-blue)
Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games and books.
Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games, books, comics, and board games.
## 🚀 Demo
@@ -14,7 +14,7 @@ You can try the app at [yamtrack.fuzzygrim.com](https://yamtrack.fuzzygrim.com)
## ✨ Features
- 🎬 Track movies, tv shows, anime, manga, games, books and comics.
- 🎬 Track movies, tv shows, anime, manga, games, books, comics, and board games.
- 📺 Track each season of a tv show individually and episodes watched.
- ⭐ Save score, status, progress, repeats (rewatches, rereads...), start and end dates, or write a note.
- 📈 Keep a tracking history with each action with a media, such as when you added it, when you started it, when you started watching it again, etc.
@@ -109,6 +109,7 @@ MAL_API=API_KEY
IGDB_ID=IGDB_ID
IGDB_SECRET=IGDB_SECRET
STEAM_API_KEY=STEAM_API_SECRET
BGG_API_TOKEN=BGG_API_TOKEN
SECRET=SECRET
DEBUG=True
```
@@ -117,6 +118,7 @@ Then run the following commands.
```bash
python -m pip install -U -r requirements-dev.txt
pre-commit install
cd src
python manage.py migrate
python manage.py runserver & celery -A config worker --beat --scheduler django --loglevel DEBUG & tailwindcss -i ./static/css/input.css -o ./static/css/tailwind.css --watch

View File

@@ -4,7 +4,7 @@ extend-exclude = ["migrations"]
[tool.ruff.lint]
select = ["ALL"]
ignore = ["ANN", "PT", "PD", "D100", "D104", "RUF012", "PLR0913", "SLF001"]
ignore = ["ANN", "PT", "PD", "D100", "D104", "RUF012", "PLR0913", "SLF001", "COM812"]
[tool.ruff.lint.pycodestyle]
max-doc-length = 88

View File

@@ -1,6 +1,7 @@
coverage==7.13.0
djlint==1.36.4
fakeredis==2.33.0
fakeredis==2.32.1
pre-commit==4.5.1
pytest-django==4.11.1
pytest-playwright==0.7.2
ruff==0.14.10

View File

@@ -3,6 +3,7 @@ apprise==1.9.6
beautifulsoup4==4.14.3
celery==5.6.0
croniter==6.0.0
defusedxml==0.7.1
Django==5.2.9
django-allauth[socialaccount]==65.13.1
django-celery-beat==2.8.1

View File

@@ -50,6 +50,7 @@ COLORS = {
"background": "bg-cyan-400",
"hex": "#06b6d4",
},
"lime": {"text": "text-lime-400", "background": "bg-lime-400", "hex": "#84cc16"},
"sky": {
"text": "text-sky-400",
"background": "bg-sky-400",
@@ -196,6 +197,23 @@ MEDIA_TYPE_CONFIG = {
5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z"/>""",
"unit": ("#", "Issue"),
},
MediaTypes.BOARDGAME.value: {
"sources": [Sources.BGG],
"default_source": Sources.BGG,
"sample_query": "Catan",
"unicode_icon": "🎲",
"verb": ("play", "played"),
"text_color": COLORS["lime"]["text"],
"stats_color": COLORS["lime"]["hex"],
"svg_icon": """
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="8" cy="8" r="2"/>
<path d="M16 8h-2"/>
<circle cx="16" cy="16" r="2"/>
<path d="M8 16v-2"/>""",
"unit": ("#", "Play"),
"date_key": "year",
},
}
# --- Status Configuration ---
@@ -334,6 +352,7 @@ def get_status_stats_color(status):
"""Get the stats color for a status."""
return get_status_property(status, "stats_color")
def get_status_background_color(status):
"""Get the background color for a status."""
return get_status_property(status, "background_color")

View File

@@ -5,6 +5,7 @@ from app import config
from app.models import (
TV,
Anime,
BoardGame,
Book,
Comic,
Episode,
@@ -235,8 +236,7 @@ class MangaForm(MediaForm):
model = Manga
labels = {
"progress": (
f"Progress "
f"({config.get_unit(MediaTypes.MANGA.value, short=False)}s)"
f"Progress ({config.get_unit(MediaTypes.MANGA.value, short=False)}s)"
),
}
@@ -290,8 +290,7 @@ class BookForm(MediaForm):
model = Book
labels = {
"progress": (
f"Progress "
f"({config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
f"Progress ({config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
),
}
@@ -305,8 +304,22 @@ class ComicForm(MediaForm):
model = Comic
labels = {
"progress": (
f"Progress "
f"({config.get_unit(MediaTypes.COMIC.value, short=False)}s)"
f"Progress ({config.get_unit(MediaTypes.COMIC.value, short=False)}s)"
),
}
class BoardgameForm(MediaForm):
"""Form for board games."""
class Meta(MediaForm.Meta):
"""Bind form to model."""
model = BoardGame
labels = {
"progress": (
"Progress "
f"({config.get_unit(MediaTypes.BOARDGAME.value, short=False)}s)"
),
}

View File

@@ -0,0 +1,102 @@
# Generated by Django 5.2.7 on 2025-11-11 14:51
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0052_alter_item_title'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BoardGame',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
('progress', models.PositiveIntegerField(default=0)),
('progressed_at', model_utils.fields.MonitorField(default=django.utils.timezone.now, monitor='progress')),
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
('start_date', models.DateTimeField(blank=True, null=True)),
('end_date', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
],
options={
'ordering': ['user', 'item', '-created_at'],
'abstract': False,
},
),
migrations.CreateModel(
name='HistoricalBoardGame',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
('progress', models.PositiveIntegerField(default=0)),
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
('start_date', models.DateTimeField(blank=True, null=True)),
('end_date', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
],
options={
'verbose_name': 'historical board game',
'verbose_name_plural': 'historical board games',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.RemoveConstraint(
model_name='item',
name='app_item_media_type_valid',
),
migrations.RemoveConstraint(
model_name='item',
name='app_item_source_valid',
),
migrations.AlterField(
model_name='item',
name='media_type',
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic'), ('boardgame', 'Board Game')], default='movie', max_length=10),
),
migrations.AlterField(
model_name='item',
name='source',
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('openlibrary', 'Open Library'), ('hardcover', 'Hardcover'), ('comicvine', 'Comic Vine'), ('bgg', 'BoardGameGeek'), ('manual', 'Manual')], max_length=20),
),
migrations.AddConstraint(
model_name='item',
constraint=models.CheckConstraint(condition=models.Q(('source__in', ['tmdb', 'mal', 'mangaupdates', 'igdb', 'openlibrary', 'hardcover', 'comicvine', 'bgg', 'manual'])), name='app_item_source_valid'),
),
migrations.AddConstraint(
model_name='item',
constraint=models.CheckConstraint(condition=models.Q(('media_type__in', ['tv', 'season', 'episode', 'movie', 'anime', 'manga', 'game', 'book', 'comic', 'boardgame'])), name='app_item_media_type_valid'),
),
migrations.AddField(
model_name='boardgame',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.item'),
),
migrations.AddField(
model_name='boardgame',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='historicalboardgame',
name='history_user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2026-02-01
from django.db import migrations
def fix_movie_progress(apps, _):
"""Set progress=1 for movies with status=Completed and progress=0."""
Movie = apps.get_model("app", "Movie")
Movie.objects.filter(status="Completed", progress=0).update(progress=1)
class Migration(migrations.Migration):
"""Migration to fix movie progress."""
dependencies = [
("app", "0053_add_boardgame"),
]
operations = [
migrations.RunPython(
fix_movie_progress, reverse_code=migrations.RunPython.noop
),
]

View File

@@ -45,6 +45,7 @@ class Sources(models.TextChoices):
OPENLIBRARY = "openlibrary", "Open Library"
HARDCOVER = "hardcover", "Hardcover"
COMICVINE = "comicvine", "Comic Vine"
BGG = "bgg", "BoardGameGeek"
MANUAL = "manual", "Manual"
@@ -60,6 +61,7 @@ class MediaTypes(models.TextChoices):
GAME = "game", "Game"
BOOK = "book", "Book"
COMIC = "comic", "Comic"
BOARDGAME = "boardgame", "Boardgame"
class Item(CalendarTriggerMixin, models.Model):
@@ -833,7 +835,7 @@ class Media(models.Model):
"""Update fields depending on the progress of the media."""
if self.progress < 0:
self.progress = 0
else:
elif self.status == Status.IN_PROGRESS.value:
max_progress = providers.services.get_media_metadata(
self.item.media_type,
self.item.media_id,
@@ -1614,3 +1616,9 @@ class Comic(Media):
"""Model for comics."""
tracker = FieldTracker()
class BoardGame(Media):
"""Model for board games."""
tracker = FieldTracker()

295
src/app/providers/bgg.py Normal file
View File

@@ -0,0 +1,295 @@
"""BoardGameGeek (BGG) API provider for board game metadata.
API Documentation: https://boardgamegeek.com/wiki/page/BGG_XML_API2
API Terms: https://boardgamegeek.com/wiki/page/XML_API_Terms_of_Use
"""
import logging
import requests
from django.conf import settings
from django.core.cache import cache
from app import helpers
from app.models import MediaTypes, Sources
from app.providers import services
logger = logging.getLogger(__name__)
base_url = "https://boardgamegeek.com/xmlapi2"
# BGG's /thing endpoint has a max of 20 IDs per request
RESULTS_PER_PAGE = 20
def handle_error(error):
"""Handle BGG API errors."""
if error.response.status_code == requests.codes.unauthorized:
raise services.ProviderAPIError(
Sources.BGG.value,
error,
"BGG API requires authorization",
)
raise services.ProviderAPIError(Sources.BGG.value, error)
def search(query, page):
"""Search for board games on BoardGameGeek."""
cache_key = (
f"search_{Sources.BGG.value}_{MediaTypes.BOARDGAME.value}_{query}_{page}"
)
data = cache.get(cache_key)
if data is None:
# Cache search results separately so page changes don't re-query BGG
search_results_cache_key = (
f"search_results_{Sources.BGG.value}_{MediaTypes.BOARDGAME.value}_{query}"
)
all_results = cache.get(search_results_cache_key)
if all_results is None:
try:
root = services.api_request(
Sources.BGG.value,
"GET",
f"{base_url}/search",
params={"query": query, "type": "boardgame"},
headers={"Authorization": f"Bearer {settings.BGG_API_TOKEN}"},
response_format="xml",
)
except requests.exceptions.HTTPError as error:
handle_error(error)
# Parse all results (BGG returns all at once, no server-side pagination)
all_results = []
for item in root.findall(".//item"):
game_id = item.get("id")
name_elem = item.find("name")
if name_elem is not None and game_id:
all_results.append(
{
"id": game_id,
"name": name_elem.get("value", "Unknown"),
}
)
cache.set(search_results_cache_key, all_results)
# Client-side pagination
total_results = len(all_results)
start_idx = (page - 1) * RESULTS_PER_PAGE
end_idx = start_idx + RESULTS_PER_PAGE
page_results = all_results[start_idx:end_idx]
# Fetch thumbnails for this page
thumbnails = _fetch_thumbnails([r["id"] for r in page_results])
results = [
{
"media_id": r["id"],
"source": Sources.BGG.value,
"media_type": MediaTypes.BOARDGAME.value,
"title": r["name"],
"image": thumbnails.get(r["id"], settings.IMG_NONE),
}
for r in page_results
]
data = helpers.format_search_response(
page,
RESULTS_PER_PAGE,
total_results,
results,
)
cache.set(cache_key, data)
return data
def _fetch_thumbnails(game_ids):
"""Fetch thumbnail images for a list of game IDs."""
if not game_ids:
return {}
try:
root = services.api_request(
Sources.BGG.value,
"GET",
f"{base_url}/thing",
params={"id": ",".join(game_ids)},
headers={"Authorization": f"Bearer {settings.BGG_API_TOKEN}"},
response_format="xml",
)
thumbnails = {}
for item in root.findall(".//item"):
game_id = item.get("id")
thumbnail_elem = item.find("thumbnail")
if thumbnail_elem is not None and thumbnail_elem.text:
thumbnails[game_id] = thumbnail_elem.text
else:
image_elem = item.find("image")
if image_elem is not None and image_elem.text:
thumbnails[game_id] = image_elem.text
except (requests.exceptions.HTTPError, services.ProviderAPIError):
logger.exception("Failed to fetch thumbnails from BGG")
return {}
else:
return thumbnails
def boardgame(media_id):
"""Return the metadata for the selected board game from BGG."""
cache_key = f"{Sources.BGG.value}_{MediaTypes.BOARDGAME.value}_{media_id}"
data = cache.get(cache_key)
if data is None:
try:
root = services.api_request(
Sources.BGG.value,
"GET",
f"{base_url}/thing",
params={"id": media_id, "stats": "1"},
headers={"Authorization": f"Bearer {settings.BGG_API_TOKEN}"},
response_format="xml",
)
except requests.exceptions.HTTPError as error:
handle_error(error)
item = root.find(".//item")
if item is None:
services.raise_not_found_error(Sources.BGG.value, media_id, "boardgame")
data = {
"media_id": media_id,
"source": Sources.BGG.value,
"source_url": f"https://boardgamegeek.com/boardgame/{media_id}",
"media_type": MediaTypes.BOARDGAME.value,
"title": get_title(item),
"max_progress": None,
"image": get_image(item),
"synopsis": get_description(item),
"genres": get_categories(item),
"score": get_score(item),
"score_count": get_score_count(item),
"details": {
"year": get_year(item),
"players": get_players(item),
"playtime": get_playtime(item),
"min_age": get_min_age(item),
"designers": get_designers(item),
"publishers": get_publishers(item),
},
}
cache.set(cache_key, data)
return data
def get_title(item):
"""Return the primary name of the game."""
name_elem = item.find(".//name[@type='primary']")
return name_elem.get("value", "Unknown") if name_elem is not None else "Unknown"
def get_image(item):
"""Return the image URL."""
image_elem = item.find("image")
if image_elem is not None and image_elem.text:
return image_elem.text
return settings.IMG_NONE
def get_description(item):
"""Return the description."""
desc_elem = item.find("description")
if desc_elem is not None and desc_elem.text:
return desc_elem.text
return "No synopsis available"
def get_year(item):
"""Return the year published."""
year_elem = item.find("yearpublished")
return year_elem.get("value") if year_elem is not None else None
def get_players(item):
"""Return the player count range."""
minplayers_elem = item.find("minplayers")
maxplayers_elem = item.find("maxplayers")
minplayers = minplayers_elem.get("value") if minplayers_elem is not None else None
maxplayers = maxplayers_elem.get("value") if maxplayers_elem is not None else None
if minplayers and maxplayers:
if minplayers == maxplayers:
return f"{minplayers} players"
return f"{minplayers}-{maxplayers} players"
return None
def get_playtime(item):
"""Return the playing time."""
playtime_elem = item.find("playingtime")
playtime = playtime_elem.get("value") if playtime_elem is not None else None
return f"{playtime} min" if playtime else None
def get_min_age(item):
"""Return the minimum age."""
minage_elem = item.find("minage")
minage = minage_elem.get("value") if minage_elem is not None else None
return f"{minage}+" if minage else None
def get_score(item):
"""Return the average rating."""
avg_rating_elem = item.find(".//statistics/ratings/average")
if avg_rating_elem is not None:
try:
return round(float(avg_rating_elem.get("value", 0)), 1)
except ValueError:
return None
return None
def get_score_count(item):
"""Return the number of ratings."""
usersrated_elem = item.find(".//statistics/ratings/usersrated")
if usersrated_elem is not None:
try:
return int(usersrated_elem.get("value", 0))
except ValueError:
return None
return None
def get_categories(item):
"""Return the list of categories."""
categories = [
link.get("value")
for link in item.findall(".//link[@type='boardgamecategory']")
if link.get("value")
]
return categories if categories else None
def get_designers(item):
"""Return the list of designers."""
designers = [
link.get("value")
for link in item.findall(".//link[@type='boardgamedesigner']")
if link.get("value")
]
return ", ".join(designers) if designers else None
def get_publishers(item):
"""Return the first few publishers."""
publishers = [
link.get("value")
for link in item.findall(".//link[@type='boardgamepublisher']")[:3]
if link.get("value")
]
return ", ".join(publishers) if publishers else None

View File

@@ -125,7 +125,7 @@ def external_game(external_id, source=ExternalGameSource.STEAM):
url = f"{base_url}/external_games"
query = (
f'fields game; where uid = "{external_id}" & '
f'external_game_source = {source};'
f"external_game_source = {source};"
)
headers = {
"Client-ID": settings.IGDB_ID,
@@ -314,7 +314,9 @@ def game(media_id):
# Check if response is empty (no results found)
if not response:
services.raise_not_found_error(
Sources.IGDB.value, media_id, "game",
Sources.IGDB.value,
media_id,
"game",
)
response = response[0] # response is a list with a single element

View File

@@ -2,6 +2,7 @@ import logging
import time
import requests
from defusedxml import ElementTree
from django.conf import settings
from pyrate_limiter import RedisBucket
from redis import ConnectionPool
@@ -10,6 +11,7 @@ from requests_ratelimiter import LimiterAdapter, LimiterSession
from app.models import MediaTypes, Sources
from app.providers import (
bgg,
comicvine,
hardcover,
igdb,
@@ -74,6 +76,10 @@ session.mount(
"https://api.hardcover.app/v1/graphql",
LimiterAdapter(per_minute=50),
)
session.mount(
"https://boardgamegeek.com/xmlapi2",
LimiterAdapter(per_second=2),
)
class ProviderAPIError(Exception):
@@ -126,8 +132,29 @@ def raise_not_found_error(provider, media_id, media_type="item"):
raise ProviderAPIError(provider, mock_error, error_msg)
def api_request(provider, method, url, params=None, data=None, headers=None):
"""Make a request to the API and return the response as a dictionary."""
def api_request(
provider,
method,
url,
params=None,
data=None,
headers=None,
response_format="json",
):
"""Make a request to the API and return the response.
Args:
provider: Provider identifier for error messages
method: HTTP method ("GET" or "POST")
url: Request URL
params: Query params for GET, JSON body for POST
data: Raw data for POST
headers: Request headers
response_format: "json" (default) or "xml" for XML parsing
Returns:
Parsed JSON dict or ElementTree for XML
"""
try:
request_kwargs = {
"url": url,
@@ -145,6 +172,9 @@ def api_request(provider, method, url, params=None, data=None, headers=None):
response = request_func(**request_kwargs)
response.raise_for_status()
if response_format == "xml":
return ElementTree.fromstring(response.text)
return response.json()
except requests.exceptions.HTTPError as error:
@@ -164,6 +194,7 @@ def api_request(provider, method, url, params=None, data=None, headers=None):
params=params,
data=data,
headers=headers,
response_format=response_format,
)
raise error from None
@@ -207,29 +238,31 @@ def get_media_metadata(
if source == Sources.HARDCOVER.value
else openlibrary.book(media_id),
MediaTypes.COMIC.value: lambda: comicvine.comic(media_id),
MediaTypes.BOARDGAME.value: lambda: bgg.boardgame(media_id),
}
return metadata_retrievers[media_type]()
def search(media_type, query, page, source=None):
"""Search for media based on the query and return the results."""
if media_type == MediaTypes.MANGA.value:
if source == Sources.MANGAUPDATES.value:
response = mangaupdates.search(query, page)
else:
response = mal.search(media_type, query, page)
elif media_type == MediaTypes.ANIME.value:
response = mal.search(media_type, query, page)
elif media_type in (MediaTypes.TV.value, MediaTypes.MOVIE.value):
response = tmdb.search(media_type, query, page)
elif media_type == MediaTypes.GAME.value:
response = igdb.search(query, page)
elif media_type == MediaTypes.BOOK.value:
if source == Sources.OPENLIBRARY.value:
response = openlibrary.search(query, page)
else:
response = hardcover.search(query, page)
elif media_type == MediaTypes.COMIC.value:
response = comicvine.search(query, page)
return response
search_handlers = {
MediaTypes.MANGA.value: lambda: (
mangaupdates.search(query, page)
if source == Sources.MANGAUPDATES.value
else mal.search(media_type, query, page)
),
MediaTypes.ANIME.value: lambda: mal.search(media_type, query, page),
MediaTypes.TV.value: lambda: tmdb.search(media_type, query, page),
MediaTypes.MOVIE.value: lambda: tmdb.search(media_type, query, page),
MediaTypes.SEASON.value: lambda: tmdb.search(MediaTypes.TV.value, query, page),
MediaTypes.EPISODE.value: lambda: tmdb.search(MediaTypes.TV.value, query, page),
MediaTypes.GAME.value: lambda: igdb.search(query, page),
MediaTypes.BOOK.value: lambda: (
openlibrary.search(query, page)
if source == Sources.OPENLIBRARY.value
else hardcover.search(query, page)
),
MediaTypes.COMIC.value: lambda: comicvine.search(query, page),
MediaTypes.BOARDGAME.value: lambda: bgg.search(query, page),
}
return search_handlers[media_type]()

View File

@@ -161,9 +161,29 @@ def movie(media_id):
url,
params=params,
)
if response.get("belongs_to_collection", {}) is not None and (
collection_id := response.get("belongs_to_collection", {}).get("id")
):
collection_response = services.api_request(
Sources.TMDB.value,
"GET",
f"{base_url}/collection/{collection_id}",
params={**base_params},
)
else:
collection_response = {}
except requests.exceptions.HTTPError as error:
handle_error(error)
# Filter out collection items from recommendations, to avoid duplicates
collection_items = get_collection(collection_response)
collection_ids = [item["media_id"] for item in collection_items]
recommended_items = response.get("recommendations", {}).get("results", [])
filtered_recommendations = [
item for item in recommended_items if item["id"] not in collection_ids
]
data = {
"media_id": media_id,
"source": Sources.TMDB.value,
@@ -186,8 +206,9 @@ def movie(media_id):
"languages": get_languages(response["spoken_languages"]),
},
"related": {
collection_response.get("name", "collection"): collection_items,
"recommendations": get_related(
response.get("recommendations", {}).get("results", [])[:15],
filtered_recommendations[:15],
MediaTypes.MOVIE.value,
),
},
@@ -580,6 +601,30 @@ def get_related(related_medias, media_type, parent_response=None):
return related
def get_collection(collection_response):
"""Format media collection list to match related media."""
def date_key(media):
date = media.get("release_date", "")
if date is None or date == "":
# If release date is unknown, sort by title after known releases
title = get_title(media)
date = f"9999-99-99-{title}"
return date
parts = sorted(collection_response.get("parts", []), key=date_key)
return [
{
"source": Sources.TMDB.value,
"media_type": MediaTypes.MOVIE.value,
"image": get_image_url(media["poster_path"]),
"media_id": media["id"],
"title": get_title(media),
}
for media in parts
]
def process_episodes(season_metadata, episodes_in_db):
"""Process the episodes for the selected season."""
episodes_metadata = []

View File

@@ -227,6 +227,7 @@ def status_color(status):
"""Return the color associated with the status."""
return config.get_status_text_color(status)
@register.filter
def status_background_color(status):
"""Return the background color associated with the status."""
@@ -236,11 +237,10 @@ def status_background_color(status):
@register.filter
def natural_day(datetime, user):
"""Format date with natural language (Today, Tomorrow, etc.)."""
# Get today's date in the current timezone
today = timezone.localdate()
# Extract just the date part for comparison
datetime_date = datetime.date()
local_dt = timezone.localtime(datetime)
datetime_date = local_dt.date()
# Calculate the difference in days
diff = datetime_date - today

View File

@@ -247,5 +247,3 @@ class EpisodeStatusTests(TestCase):
self.tv.refresh_from_db()
self.assertEqual(self.tv.status, Status.PLANNING.value)

View File

@@ -47,5 +47,3 @@ class ItemModel(TestCase):
episode_number=2,
)
self.assertEqual(str(item), "Test Show S1E2")

View File

@@ -71,5 +71,3 @@ class MediaModel(TestCase):
Anime.objects.get(item__media_id="1", user=self.user).progress,
26,
)

View File

@@ -816,5 +816,3 @@ class MediaManagerTests(TestCase):
).first()
self.assertIsNone(non_existent)

View File

@@ -320,5 +320,3 @@ class TVStatusTests(TestCase):
season1 = Season.objects.get(pk=self.season1.pk)
self.assertEqual(season1.status, original_season1_status)

View File

@@ -631,5 +631,3 @@ class Metadata(TestCase):
hardcover.handle_error(error)
self.assertEqual(cm.exception.provider, Sources.HARDCOVER.value)

View File

@@ -114,5 +114,3 @@ class Search(TestCase):
"""Test the search method for books from Hardcover with no results."""
response = hardcover.search("xjkqzptmvnsieurytowahdbfglc", 1)
self.assertEqual(response["results"], [])

View File

@@ -373,7 +373,9 @@ class ServicesTests(TestCase):
def test_get_media_metadata_tmdb_episode_not_found(self, mock_episode):
"""Test the get_media_metadata function for TMDB episodes that don't exist."""
mock_response = type(
"Response", (), {"status_code": 404, "text": "Episode not found"},
"Response",
(),
{"status_code": 404, "text": "Episode not found"},
)()
mock_error = type("Error", (), {"response": mock_response})()
mock_episode.side_effect = services.ProviderAPIError(
@@ -394,7 +396,6 @@ class ServicesTests(TestCase):
mock_episode.assert_called_once_with("1396", 1, "3")
@patch("app.providers.hardcover.book")
def test_get_media_metadata_hardcover_book(self, mock_book):
"""Test the get_media_metadata function for books from Hardcover."""

View File

@@ -250,5 +250,3 @@ class DeleteMedia(TestCase):
Episode.objects.filter(related_season__user=self.user).count(),
0,
)

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from django.test import TestCase
@@ -230,5 +229,3 @@ class CreateEntryViewTests(TestCase):
self.client.post(reverse("create_entry"), form_data)
self.assertEqual(Item.objects.count(), initial_count)

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
@@ -132,5 +131,3 @@ class DeleteHistoryRecordViewTests(TestCase):
)
self.assertEqual(response.status_code, 404)

View File

@@ -166,5 +166,3 @@ class HomeViewTests(TestCase):
response.context["media_list"]["total"],
15,
) # 15 TV shows total

View File

@@ -114,5 +114,3 @@ class MediaDetailsViewTests(TestCase):
Sources.TMDB.value,
[1],
)

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
@@ -103,5 +102,3 @@ class MediaListViewTests(TestCase):
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "app/components/media_table_items.html")

View File

@@ -53,5 +53,3 @@ class MediaSearchViewTests(TestCase):
1,
Sources.TMDB.value,
)

View File

@@ -157,5 +157,3 @@ class ProgressEditAnime(TestCase):
)
self.assertEqual(Anime.objects.get(item__media_id="1").progress, 1)

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
@@ -67,5 +66,3 @@ class StatisticsViewTests(TestCase):
)
self.assertTrue(date_is_none)

View File

@@ -91,5 +91,3 @@ class TrackModalViewTests(TestCase):
response.context["form"].initial["media_type"],
MediaTypes.MOVIE.value,
)

View File

@@ -257,13 +257,16 @@ LOGGING = {
"disable_existing_loggers": False,
"loggers": {
"requests_ratelimiter.requests_ratelimiter": {
"level": "DEBUG" if DEBUG else "WARNING",
"level": "WARNING",
},
"psycopg": {
"level": "DEBUG" if DEBUG else "WARNING",
},
"urllib3": {
"level": "DEBUG" if DEBUG else "WARNING",
"level": "WARNING",
},
"celery.utils.functional": {
"level": "WARNING",
},
},
"formatters": {
@@ -374,6 +377,15 @@ IGDB_SECRET = config(
)
IGDB_NSFW = config("IGDB_NSFW", default=False, cast=bool)
# BoardGameGeek API Token - Register at https://boardgamegeek.com/using_the_xml_api
BGG_API_TOKEN = config(
"BGG_API_TOKEN",
default=secret(
"BGG_API_TOKEN_FILE",
"92f43ab1-d1d5-4e18-8b82-d1f56dc12927",
),
)
STEAM_API_KEY = config(
"STEAM_API_KEY",
default=secret(
@@ -483,7 +495,7 @@ SELECT2_THEME = "tailwindcss-4"
# Celery settings
CELERY_BROKER_URL = REDIS_URL
CELERY_BROKER_URL = config("CELERY_REDIS_URL", default=REDIS_URL)
CELERY_TIMEZONE = TIME_ZONE
if REDIS_PREFIX:

View File

@@ -289,10 +289,14 @@ def process_anime_bulk(items, events_bulk):
if episodes:
for episode in episodes:
episode_datetime = datetime.fromtimestamp(
episode["airingAt"],
tz=ZoneInfo("UTC"),
)
# when schedule less than total episodes and end date is null
if episode["airingAt"] is None:
episode_datetime = datetime.min.replace(tzinfo=ZoneInfo("UTC"))
else:
episode_datetime = datetime.fromtimestamp(
episode["airingAt"],
tz=ZoneInfo("UTC"),
)
events_bulk.append(
Event(
item=item,
@@ -302,7 +306,7 @@ def process_anime_bulk(items, events_bulk):
)
else:
logger.info(
"Anime: %s (%s), not found in AniList",
"Anime: %s (%s), not proccesed by AniList",
item.title,
item.media_id,
)
@@ -353,35 +357,52 @@ def get_anime_schedule_bulk(media_ids):
total_episodes = media["episodes"]
mal_id = str(media["idMal"])
# First check if we know the total episode count
if total_episodes:
if airing_schedule:
# Filter out episodes beyond the total count
original_length = len(airing_schedule)
airing_schedule = [
episode
for episode in airing_schedule
if episode["episode"] <= total_episodes
]
if not total_episodes:
continue
# Log if any filtering occurred
if original_length > len(airing_schedule):
logger.info(
"Filtered episodes for MAL ID %s - keep only %s episodes",
mal_id,
total_episodes,
)
if airing_schedule:
# Filter out episodes beyond the total count
original_length = len(airing_schedule)
airing_schedule = [
episode
for episode in airing_schedule
if episode["episode"] <= total_episodes
]
# Add final episode if schedule is missing or incomplete
if (
not airing_schedule
or airing_schedule[-1]["episode"] < total_episodes
):
end_date_timestamp = anilist_date_parser(media["endDate"])
if end_date_timestamp:
airing_schedule.append(
{"episode": total_episodes, "airingAt": end_date_timestamp},
)
# Log if any filtering occurred
if original_length > len(airing_schedule):
logger.info(
"Filtered episodes for MAL ID %s - keep only %s episodes",
mal_id,
total_episodes,
)
# incomplete data from AniList
if not airing_schedule or airing_schedule[-1]["episode"] < total_episodes:
mal_metadata = services.get_media_metadata(
media_type=MediaTypes.ANIME.value,
media_id=mal_id,
source=Sources.MAL.value,
)
mal_total_episodes = mal_metadata["max_progress"]
if mal_total_episodes and mal_total_episodes > total_episodes:
logger.info(
"MAL ID %s - MAL has %s episodes, AniList has %s",
mal_id,
mal_total_episodes,
total_episodes,
)
continue
logger.info(
"Adding final episode for MAL ID %s - Ep %s",
mal_id,
total_episodes,
)
end_date_timestamp = anilist_date_parser(media["endDate"])
airing_schedule.append(
{"episode": total_episodes, "airingAt": end_date_timestamp},
)
# Store the processed schedule
all_data[mal_id] = airing_schedule
@@ -719,33 +740,32 @@ def process_other(item, events_bulk):
return
date_key = config.get_date_key(item.media_type)
content_number = metadata["max_progress"]
if date_key in metadata["details"] and metadata["details"][date_key]:
try:
if date_key in metadata["details"] and content_number:
if metadata["details"][date_key]:
content_datetime = date_parser(metadata["details"][date_key])
except ValueError:
pass
else:
content_number = (
None
if item.media_type == MediaTypes.MOVIE.value
else metadata["max_progress"]
)
events_bulk.append(
Event(
item=item,
content_number=content_number,
datetime=content_datetime,
),
)
content_datetime = datetime.min.replace(tzinfo=ZoneInfo("UTC"))
elif item.source == Sources.MANGAUPDATES.value and metadata["max_progress"]:
if item.media_type == MediaTypes.MOVIE.value:
content_number = None
events_bulk.append(
Event(
item=item,
content_number=content_number,
datetime=content_datetime,
),
)
elif item.source == Sources.MANGAUPDATES.value and content_number:
# MangaUpdates doesn't have an end date, so use a placeholder
content_datetime = datetime.min.replace(tzinfo=ZoneInfo("UTC"))
events_bulk.append(
Event(
item=item,
content_number=metadata["max_progress"],
content_number=content_number,
datetime=content_datetime,
),
)

View File

@@ -160,14 +160,12 @@ class ReloadCalendarTaskTests(TestCase):
datetime=timezone.now(),
),
)
mock_process_other.side_effect = (
lambda item, events_bulk: events_bulk.append(
Event(
item=item,
content_number=1,
datetime=timezone.now(),
),
)
mock_process_other.side_effect = lambda item, events_bulk: events_bulk.append(
Event(
item=item,
content_number=1,
datetime=timezone.now(),
),
)
# Setup mock for process_anime_bulk to create events for anime items
mock_process_anime_bulk.side_effect = lambda items, events_bulk: [
@@ -214,14 +212,12 @@ class ReloadCalendarTaskTests(TestCase):
def test_fetch_releases_specific_items(self, mock_process_other):
"""Test fetch_releases with specific items to process."""
# Setup mock
mock_process_other.side_effect = (
lambda item, events_bulk: events_bulk.append(
Event(
item=item,
content_number=1,
datetime=timezone.now(),
),
)
mock_process_other.side_effect = lambda item, events_bulk: events_bulk.append(
Event(
item=item,
content_number=1,
datetime=timezone.now(),
),
)
# Call the task with specific items
@@ -703,6 +699,7 @@ class ReloadCalendarTaskTests(TestCase):
"""Test process_other with invalid date."""
# Setup mock with invalid date
mock_get_media_metadata.return_value = {
"max_progress": None,
"details": {
"release_date": "invalid-date",
},
@@ -720,6 +717,7 @@ class ReloadCalendarTaskTests(TestCase):
"""Test process_other with no date."""
# Setup mock with no date
mock_get_media_metadata.return_value = {
"max_progress": None,
"details": {},
}
@@ -767,10 +765,13 @@ class ReloadCalendarTaskTests(TestCase):
expected_date = datetime.datetime.fromtimestamp(870739200, tz=ZoneInfo("UTC"))
self.assertEqual(events_bulk[0].datetime, expected_date)
@patch("events.calendar.services.get_media_metadata")
@patch("events.calendar.services.api_request")
def test_process_anime_bulk_no_matching_anime_anilist(self, mock_api_request):
def test_process_anime_bulk_no_matching_anime_anilist(
self, mock_api_request, mock_get_media_metadata
):
"""Test process_anime_bulk with no matching anime in Anilist."""
# Setup mock with empty media list
# Setup mock with empty media list (AniList returns nothing)
mock_api_request.return_value = {
"data": {
"Page": {
@@ -779,6 +780,11 @@ class ReloadCalendarTaskTests(TestCase):
},
},
}
# Mock the fallback to MAL via get_media_metadata
mock_get_media_metadata.return_value = {
"max_progress": 1,
"details": {"end_date": "1997-08-05"},
}
# Process anime items
events_bulk = []

View File

@@ -384,6 +384,7 @@ class SimklImporter:
user=self.user,
status=movie_status,
score=movie["user_rating"],
progress=1 if movie_status == Status.COMPLETED.value else 0,
start_date=self._get_date(movie.get("last_watched_at")),
end_date=self._get_date(movie.get("last_watched_at")),
notes=movie["memo"]["text"] if movie["memo"] != {} else "",

View File

@@ -397,6 +397,7 @@ class TraktImporter:
user=self.user,
end_date=watched_at,
status=Status.COMPLETED.value,
progress=1,
)
movie_obj._history_date = parse_datetime(watched_at)
@@ -605,7 +606,7 @@ class TraktImporter:
msg = f"Error processing comment entry: {entry}"
raise MediaImportUnexpectedError(msg) from e
def _process_generic_entry(self, entry, entry_type, attribute_updates=None):
def _process_generic_entry(self, entry, entry_type, attribute_updates):
"""Process a generic entry (watchlist, rating, or comment)."""
if entry["type"] == "movie":
logger.info(
@@ -613,12 +614,18 @@ class TraktImporter:
entry["movie"]["title"],
entry_type,
)
# Movies with Completed status (from ratings and comments)
# should have progress=1
status = attribute_updates.get("status", Status.COMPLETED.value)
if status == Status.COMPLETED.value:
attribute_updates["progress"] = 1
self._process_media_item(
entry,
entry["movie"],
MediaTypes.MOVIE.value,
app.models.Movie,
attribute_updates or {},
attribute_updates,
)
elif entry["type"] == "show":
logger.info(
@@ -631,7 +638,7 @@ class TraktImporter:
entry["show"],
MediaTypes.TV.value,
app.models.TV,
attribute_updates or {},
attribute_updates,
)
elif entry["type"] == "season":
logger.info(
@@ -645,7 +652,7 @@ class TraktImporter:
entry["show"],
MediaTypes.SEASON.value,
app.models.Season,
attribute_updates or {},
attribute_updates,
entry["season"]["number"],
)
@@ -655,7 +662,7 @@ class TraktImporter:
media_data,
media_type,
model_class,
defaults=None,
defaults,
season_number=None,
):
"""Process media items for watchlist, ratings, and comments."""

View File

@@ -204,4 +204,3 @@ class YamtrackImporter:
msg = f"Missing metadata for: {row}"
raise MediaImportError(msg)

View File

@@ -101,5 +101,3 @@ class ImportAniList(TestCase):
"new",
"fhdsufdsu",
)

View File

@@ -45,5 +45,3 @@ class ImportGoodreads(TestCase):
read_book = Book.objects.get(status=Status.IN_PROGRESS.value)
self.assertEqual(read_book.status, Status.IN_PROGRESS.value)
self.assertEqual(read_book.progress, 0)

View File

@@ -171,5 +171,3 @@ class HelpersTest(TestCase):
schedule = CrontabSchedule.objects.first()
self.assertEqual(schedule.day_of_week, "*/2")

View File

@@ -39,5 +39,3 @@ class ImportHowLongToBeat(TestCase):
game.history.first().history_date,
datetime(2024, 2, 9, 15, 54, 48, tzinfo=UTC),
)

View File

@@ -137,5 +137,3 @@ class ImportIMDB(TestCase):
self.assertEqual(imported_counts.get(MediaTypes.MOVIE.value, 0), 5)
self.assertIn("They were matched to the same TMDB ID 155", warnings)

View File

@@ -114,5 +114,3 @@ class ImportKitsu(TestCase):
self.assertEqual(instance.progress, 26)
self.assertEqual(instance.status, Status.COMPLETED.value)
self.assertEqual(instance.notes, "Great series!")

View File

@@ -87,5 +87,3 @@ class ImportMAL(TestCase):
self.user,
"new",
)

View File

@@ -105,6 +105,7 @@ class ImportSimkl(TestCase):
movie_obj = Movie.objects.get(item=movie_item)
self.assertEqual(movie_obj.status, Status.COMPLETED.value)
self.assertEqual(movie_obj.score, 9)
self.assertEqual(movie_obj.progress, 1)
anime_item = Item.objects.get(media_type=MediaTypes.ANIME.value)
self.assertEqual(anime_item.title, "Cowboy Bebop")
@@ -229,5 +230,3 @@ class ImportSimkl(TestCase):
for episode in season1_episodes:
self.assertIsNotNone(episode.end_date)

View File

@@ -48,6 +48,10 @@ class ImportTrakt(TestCase):
self.assertEqual(len(trakt_importer.bulk_media[MediaTypes.MOVIE.value]), 1)
self.assertEqual(len(trakt_importer.media_instances[MediaTypes.MOVIE.value]), 1)
# Verify progress is set to 1 for completed movies
movie_obj = trakt_importer.bulk_media[MediaTypes.MOVIE.value][0]
self.assertEqual(movie_obj.progress, 1)
# Process the same movie again to test repeat handling
trakt_importer.process_watched_movie(movie_entry)
self.assertEqual(len(trakt_importer.bulk_media[MediaTypes.MOVIE.value]), 2)
@@ -269,5 +273,3 @@ class ImportTrakt(TestCase):
self.assertEqual(importer.username, "testuser")
self.assertIsNone(importer.refresh_token)
self.assertEqual(importer.mode, "new")

View File

@@ -135,6 +135,57 @@ class ImportYamtrackPartials(TestCase):
self.assertEqual(Book.objects.filter(user=self.user).count(), 3)
self.assertEqual(Movie.objects.filter(user=self.user).count(), 1)
def test_season_episode_search_by_title(self):
"""Test that seasons and episodes can be resolved by title (no media_id).
This test verifies the fix that allows searching for SEASON and EPISODE
media types by searching for the parent TV show on TMDB. Before the fix,
services.search() didn't handle SEASON/EPISODE types and would fail with:
UnboundLocalError: cannot access local variable 'response'
"""
test_rows = [
# Season with title only (no media_id)
{
"media_id": "",
"source": "",
"media_type": "season",
"title": "Friends",
"image": "",
"season_number": "1",
"episode_number": "",
},
# Episode with title only (no media_id)
{
"media_id": "",
"source": "",
"media_type": "episode",
"title": "Friends",
"image": "",
"season_number": "1",
"episode_number": "1",
},
]
importer = yamtrack.YamtrackImporter(None, self.user, "new")
for row in test_rows:
original_row = row.copy()
importer._handle_missing_metadata(
row,
row["media_type"],
int(row["season_number"]) if row["season_number"] else None,
int(row["episode_number"]) if row["episode_number"] else None,
)
# Verify media_id was resolved from TMDB search
self.assertNotEqual(row["media_id"], original_row["media_id"])
self.assertEqual(str(row["media_id"]), "1668") # Friends TV show ID
self.assertEqual(row["source"], "tmdb")
# Title and image should be populated from TMDB
self.assertNotEqual(row["title"], "")
self.assertNotEqual(row["image"], "")
def test_end_dates(self):
"""Test end dates during import."""
book = Book.objects.filter(user=self.user).first()
@@ -158,5 +209,3 @@ class ImportYamtrackPartials(TestCase):
books[2].end_date,
datetime(2024, 3, 9, 0, 0, 0, tzinfo=UTC),
)

View File

@@ -24,6 +24,7 @@
--color-amber-700: oklch(55.5% 0.163 48.998);
--color-yellow-400: oklch(85.2% 0.199 91.936);
--color-yellow-600: oklch(68.1% 0.162 75.834);
--color-lime-400: oklch(84.1% 0.238 128.85);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-emerald-300: oklch(84.5% 0.143 164.978);
--color-emerald-400: oklch(76.5% 0.177 163.223);
@@ -45,6 +46,7 @@
--color-fuchsia-700: oklch(51.8% 0.253 323.949);
--color-sky-400: oklch(0.746 0.16 232.661);
--color-slate-200: oklch(92.9% 0.013 255.508);
--color-slate-400: oklch(70.4% 0.04 256.788);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-300: oklch(87.2% 0.01 258.338);
@@ -1325,6 +1327,9 @@
.bg-indigo-700 {
background-color: var(--color-indigo-700);
}
.bg-lime-400 {
background-color: var(--color-lime-400);
}
.bg-orange-400 {
background-color: var(--color-orange-400);
}
@@ -1662,6 +1667,9 @@
.text-indigo-600 {
color: var(--color-indigo-600);
}
.text-lime-400 {
color: var(--color-lime-400);
}
.text-orange-400 {
color: var(--color-orange-400);
}
@@ -1743,9 +1751,16 @@
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-\[1\.5px\] {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1.5px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-indigo-500 {
--tw-ring-color: var(--color-indigo-500);
}
.ring-slate-400 {
--tw-ring-color: var(--color-slate-400);
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
@@ -2315,11 +2330,6 @@
--tw-ring-color: var(--color-indigo-200);
}
}
.focus\:ring-indigo-300 {
&:focus {
--tw-ring-color: var(--color-indigo-300);
}
}
.focus\:ring-indigo-400 {
&:focus {
--tw-ring-color: var(--color-indigo-400);

View File

@@ -1,6 +1,6 @@
{% load app_tags %}
<div class="{% if secondary_color %}bg-[#39404b]{% else %}bg-[#2a2f35]{% endif %} rounded-lg overflow-hidden shadow-lg relative"
<div class="{% if secondary_color %}bg-[#39404b]{% else %}bg-[#2a2f35]{% endif %} rounded-lg overflow-hidden shadow-lg relative {% if active %}ring-[1.5px] ring-slate-400{% endif %}"
x-data="{ trackOpen: false, listsOpen: false, historyOpen: false }">
<div class="relative">
<a href="{{ item|media_url }}">

View File

@@ -478,7 +478,12 @@
<h2 class="text-xl font-bold mb-4">{{ name|no_underscore|title }}</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-4">
{% for result in related_items %}
{% include "app/components/media_card.html" with item=result.item title=result.item.season_title|default:result.item.title media=result.media %}
{# Set active to highlight the current movie in a collection. Avoid TV media, since seasons have same ID as the parent show #}
{% if media_type == MediaTypes.MOVIE.value %}
{% include "app/components/media_card.html" with item=result.item title=result.item.season_title|default:result.item.title media=result.media active=media.media_id|str_equals:result.item.media_id %}
{% else %}
{% include "app/components/media_card.html" with item=result.item title=result.item.season_title|default:result.item.title media=result.media %}
{% endif %}
{% endfor %}
</div>
</section>

View File

@@ -105,16 +105,14 @@
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95">
{% for value, label in sort_choices %}
{% if media_type != MediaTypes.MOVIE.value or value != 'progress' %}
<button hx-get="{% url 'medialist' media_type %}"
{% if media_list %} hx-target="{{ layout_class }}" {% else %} hx-target="#empty_list" {% endif %}
hx-include="#filter-form"
hx-indicator="#loading-indicator"
class="block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer"
:class="sort === '{{ value }}' ? 'bg-indigo-600 text-white font-medium' : 'text-gray-300 hover:bg-[#454d5a] hover:text-white'"
@click="sort = '{{ value }}'; open = false"
type="button">{{ label }}</button>
{% endif %}
<button hx-get="{% url 'medialist' media_type %}"
{% if media_list %} hx-target="{{ layout_class }}" {% else %} hx-target="#empty_list" {% endif %}
hx-include="#filter-form"
hx-indicator="#loading-indicator"
class="block w-full px-4 py-2 text-left text-sm transition-colors cursor-pointer"
:class="sort === '{{ value }}' ? 'bg-indigo-600 text-white font-medium' : 'text-gray-300 hover:bg-[#454d5a] hover:text-white'"
@click="sort = '{{ value }}'; open = false"
type="button">{{ label }}</button>
{% endfor %}
</div>
</div>

View File

@@ -33,8 +33,11 @@ class CustomUserAdmin(UserAdmin):
list_display = ("username", "is_staff", "is_active", "is_demo", "last_login")
list_filter = ("is_staff", "is_active", "is_demo")
def get_fieldsets(self, _, __=None):
def get_fieldsets(self, _, obj=None):
"""Customize the fieldsets for the User model in the admin interface."""
if not obj:
return self.add_fieldsets
fieldsets = [
(None, {"fields": ("username", "password")}),
("Permissions", {"fields": ("is_staff", "is_active")}),

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.7 on 2025-11-11 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0053_add_boardgame'),
('auth', '0012_alter_user_first_name_max_length'),
('users', '0042_add_date_time_format_preferences'),
]
operations = [
migrations.RemoveConstraint(
model_name='user',
name='last_search_type_valid',
),
migrations.AddField(
model_name='user',
name='boardgame_enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='user',
name='boardgame_layout',
field=models.CharField(choices=[('grid', 'Grid'), ('table', 'Table')], default='grid', max_length=20),
),
migrations.AddField(
model_name='user',
name='boardgame_sort',
field=models.CharField(choices=[('score', 'Rating'), ('title', 'Title'), ('progress', 'Progress'), ('start_date', 'Start Date'), ('end_date', 'End Date')], default='score', max_length=20),
),
migrations.AddField(
model_name='user',
name='boardgame_status',
field=models.CharField(choices=[('All', 'All'), ('Completed', 'Completed'), ('In progress', 'In Progress'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='All', max_length=20),
),
migrations.AlterField(
model_name='user',
name='home_sort',
field=models.CharField(choices=[('upcoming', 'Upcoming'), ('recent', 'Recent'), ('completion', 'Completion'), ('episodes_left', 'Episodes Left'), ('title', 'Title')], default='upcoming', max_length=20),
),
migrations.AlterField(
model_name='user',
name='last_search_type',
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic'), ('boardgame', 'Board Game')], default='tv', max_length=10),
),
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(condition=models.Q(('last_search_type__in', ['tv', 'movie', 'anime', 'manga', 'game', 'book', 'comic', 'boardgame'])), name='last_search_type_valid'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-26 11:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0042_add_date_time_format_preferences'),
]
operations = [
migrations.AddField(
model_name='user',
name='progress_bar',
field=models.BooleanField(default=False, help_text='Show progress bar'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.9 on 2026-01-26 11:02
from django.db import migrations, models
class Migration(migrations.Migration):
"""Migration for user progress bar setting."""
dependencies = [
("users", "0043_add_boardgame_preferences"),
]
operations = [
migrations.AddField(
model_name="user",
name="progress_bar",
field=models.BooleanField(default=False, help_text="Show progress bar"),
),
]

View File

@@ -267,6 +267,24 @@ class User(AbstractUser):
choices=MediaStatusChoices.choices,
)
# Media type preferences: Board Games
boardgame_enabled = models.BooleanField(default=True)
boardgame_layout = models.CharField(
max_length=20,
default=LayoutChoices.GRID,
choices=LayoutChoices.choices,
)
boardgame_sort = models.CharField(
max_length=20,
default=MediaSortChoices.SCORE,
choices=MediaSortChoices.choices,
)
boardgame_status = models.CharField(
max_length=20,
default=MediaStatusChoices.ALL,
choices=MediaStatusChoices.choices,
)
# UI preferences
clickable_media_cards = models.BooleanField(
default=False,

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.test import TestCase
@@ -92,5 +91,3 @@ class DeleteImportScheduleTests(TestCase):
self.assertIn("Import schedule not found", str(messages[0]))
self.assertTrue(PeriodicTask.objects.filter(id=self.other_task.id).exists())

View File

@@ -1,4 +1,3 @@
from django.contrib import auth
from django.contrib.auth import get_user_model
from django.test import TestCase
@@ -43,5 +42,3 @@ class DemoProfileTests(TestCase):
)
self.assertTrue(auth.get_user(self.client).check_password("testpass123"))
self.assertContains(response, "not allowed for the demo account")

View File

@@ -217,5 +217,3 @@ class NotificationTests(TestCase):
messages = list(get_messages(response.wsgi_request))
self.assertEqual(len(messages), 1)
self.assertIn("Failed", str(messages[0]))

View File

@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.test import TestCase

View File

@@ -1,4 +1,3 @@
from django.contrib import auth
from django.contrib.auth import get_user_model
from django.test import TestCase
@@ -50,5 +49,3 @@ class Profile(TestCase):
)
self.assertTrue(auth.get_user(self.client).check_password("12345"))
self.assertContains(response, "Your old password was entered incorrectly")

View File

@@ -48,5 +48,3 @@ class RegenerateTokenTests(TestCase):
messages = list(get_messages(response.wsgi_request))
self.assertEqual(len(messages), 1)
self.assertIn("Token regenerated successfully", str(messages[0]))