diff --git a/.gitignore b/.gitignore index a34aef120..db11ac087 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,13 @@ CLAUDE.md requests.http TODO.txt .codex + +# NodeJS +node_modules/ + +# Database +db/*.sqlite3 +db/db.sqlite3 + +# Tailwind +src/static/css/tailwind.css \ No newline at end of file diff --git a/src/app/helpers.py b/src/app/helpers.py index d4e0948ee..c3384f34e 100644 --- a/src/app/helpers.py +++ b/src/app/helpers.py @@ -8,7 +8,7 @@ from django.utils.encoding import iri_to_uri from django.utils.http import url_has_allowed_host_and_scheme -from app.models import BasicMedia, MediaTypes, Status +from app.models import BasicMedia, MediaTypes def minutes_to_hhmm(total_minutes): @@ -29,7 +29,7 @@ def redirect_back(request): parsed_url = urlparse(next_url) # Get the query parameters and remove params we don't want - query_params = dict(parse_qsl(parsed_url.query, keep_blank_values=True)) + query_params = dict(parse_qsl(parsed_url.query)) query_params.pop("page", None) query_params.pop("load_media_type", None) @@ -66,7 +66,7 @@ def format_search_response(page, per_page, total_results, results): } -def enrich_items_with_user_data(request, items, section_name): +def enrich_items_with_user_data(request, items): """Enrich a list of items with user tracking data.""" if not items: return [] @@ -118,18 +118,9 @@ def enrich_items_with_user_data(request, items, section_name): else: key = (str(item["media_id"]), item["source"]) - media_item = media_lookup.get(key) - if ( - request.user.hide_completed_recommendations - and section_name == "recommendations" - and media_item - and media_item.status == Status.COMPLETED.value - ): - continue - enriched_item = { "item": item, - "media": media_item, + "media": media_lookup.get(key), } enriched_items.append(enriched_item) diff --git a/src/app/models.py b/src/app/models.py index 512326187..31115001c 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -223,7 +223,16 @@ def get_historical_models(self): """Return list of historical model names.""" return [f"historical{media_type}" for media_type in MediaTypes.values] - def get_media_list(self, user, media_type, status_filter, sort_filter, search=None): + def get_media_list( + self, + user, + media_type, + status_filter, + sort_filter, + search=None, + exclude_item_ids=None, + include_item_ids=None, + ): """Get media list based on filters, sorting and search.""" model = apps.get_model(app_label="app", model_name=media_type) queryset = model.objects.filter(user=user.id) @@ -234,6 +243,12 @@ def get_media_list(self, user, media_type, status_filter, sort_filter, search=No if search: queryset = queryset.filter(item__title__icontains=search) + if exclude_item_ids: + queryset = queryset.exclude(item_id__in=exclude_item_ids) + + if include_item_ids: + queryset = queryset.filter(item_id__in=include_item_ids) + queryset = queryset.annotate( repeats=Window( expression=Count("id"), @@ -409,7 +424,15 @@ def _sort_generic_media_list(self, queryset, sort_filter): models.functions.Lower("item__title"), ) - def get_in_progress(self, user, sort_by, items_limit, specific_media_type=None): + def get_in_progress( + self, + user, + sort_by, + items_limit, + specific_media_type=None, + exclude_item_ids=None, + include_item_ids=None, + ): """Get a media list of in progress media by type.""" list_by_type = {} media_types = self._get_media_types_to_process(user, specific_media_type) @@ -421,8 +444,22 @@ def get_in_progress(self, user, sort_by, items_limit, specific_media_type=None): media_type=media_type, status_filter=Status.IN_PROGRESS.value, sort_filter=None, + exclude_item_ids=exclude_item_ids, + include_item_ids=include_item_ids, ) + if not media_list: + continue + + # Filter out special episodes (Season 0) if disabled + if ( + media_type == MediaTypes.SEASON.value + and not user.show_special_episodes + ): + media_list = [ + m for m in media_list if m.item.season_number != 0 + ] + if not media_list: continue diff --git a/src/app/providers/comicvine.py b/src/app/providers/comicvine.py index 0576cd8aa..9d27ac87b 100644 --- a/src/app/providers/comicvine.py +++ b/src/app/providers/comicvine.py @@ -125,9 +125,9 @@ def comic(media_id): ) publisher_id = response.get("publisher", {}).get("id") - publisher_comics = [] + recommendations = [] if publisher_id: - publisher_comics = get_publisher_comics(publisher_id, media_id) + recommendations = get_similar_comics(publisher_id, media_id) data = { "media_id": media_id, @@ -154,7 +154,7 @@ def comic(media_id): "last_updated": response.get("date_last_updated").split()[0], }, "related": { - "recommendations": publisher_comics, + "from_the_same_publisher": recommendations, }, # used for events fetching "last_issue_id": response["last_issue"]["id"], @@ -249,9 +249,9 @@ def get_people(response): return [person["name"] for person in people[:5] if isinstance(person, dict)] -def get_publisher_comics(publisher_id, current_id, limit=15): - """Get comics from the same publisher.""" - cache_key = f"{Sources.COMICVINE.value}_publisher_{publisher_id}_{current_id}" +def get_similar_comics(publisher_id, current_id, limit=10): + """Get similar comics from the same publisher.""" + cache_key = f"{Sources.COMICVINE.value}_similar_{publisher_id}_{current_id}" data = cache.get(cache_key) if data is None: diff --git a/src/app/providers/igdb.py b/src/app/providers/igdb.py index 912297d2f..fb6345f2d 100644 --- a/src/app/providers/igdb.py +++ b/src/app/providers/igdb.py @@ -229,7 +229,7 @@ def search(query, page): Sources.IGDB.value, "POST", url, - data=multiquery, + data=data, headers=headers, ) @@ -282,8 +282,7 @@ def game(media_id): "expansions.name,expansions.cover.image_id," "standalone_expansions.name,standalone_expansions.cover.image_id," "expanded_games.name,expanded_games.cover.image_id," - "similar_games.name,similar_games.cover.image_id," - "dlcs.name,dlcs.cover.image_id;" + "similar_games.name,similar_games.cover.image_id;" f"where id = {media_id};" ) headers = { @@ -345,7 +344,6 @@ def game(media_id): "remasters": get_related(response.get("remasters")), "remakes": get_related(response.get("remakes")), "expansions": get_related(response.get("expansions")), - "dlcs": get_related(response.get("dlcs")), "standalone_expansions": get_related( response.get("standalone_expansions"), ), diff --git a/src/app/providers/tmdb.py b/src/app/providers/tmdb.py index 9a7c60234..47e950cb8 100644 --- a/src/app/providers/tmdb.py +++ b/src/app/providers/tmdb.py @@ -235,7 +235,7 @@ def movie(media_id): "related": { collection_response.get("name", "collection"): collection_items, "recommendations": get_related( - filtered_recommendations, + filtered_recommendations[:15], MediaTypes.MOVIE.value, ), }, @@ -445,7 +445,7 @@ def process_tv(response): response, ), "recommendations": get_related( - response.get("recommendations", {}).get("results", []), + response.get("recommendations", {}).get("results", [])[:15], MediaTypes.TV.value, ), }, diff --git a/src/app/templatetags/app_tags.py b/src/app/templatetags/app_tags.py index c1d2d8720..0a1b669f4 100644 --- a/src/app/templatetags/app_tags.py +++ b/src/app/templatetags/app_tags.py @@ -433,18 +433,3 @@ def get_pagination_range(current_page, total_pages, window): result.append(total_pages) return result - - -@register.filter -def show_media_score(rating, user): - """ - Return if we should show the rating of a media. - - Args: - rating: the rating value of the media - user: the user to check preferences for - - Returns: - True if we should show the media score - """ - return rating is not None and (not user.hide_zero_rating or rating > 0) diff --git a/src/app/tests/test_helpers.py b/src/app/tests/test_helpers.py index ddc48ceaa..3ac2425a1 100644 --- a/src/app/tests/test_helpers.py +++ b/src/app/tests/test_helpers.py @@ -154,7 +154,7 @@ def test_enrich_items_with_user_data(self): }, ] - enriched_items = enrich_items_with_user_data(self.request, raw_items, "test") + enriched_items = enrich_items_with_user_data(self.request, raw_items) self.assertEqual(len(enriched_items), 3) # Scenario 1: Existing movie with user tracking data @@ -192,60 +192,3 @@ def test_enrich_items_with_user_data(self): unknown_movie_enriched["item"]["description"], "This movie doesn't exist in our database", ) - - def test_hide_completed_recommendations_enabled(self): - """Test that completed items are hidden when preference is enabled.""" - self.user.hide_completed_recommendations = True - self.user.save() - - raw_items = [ - { - "media_id": "238", # This is our completed movie - "source": Sources.TMDB.value, - "media_type": MediaTypes.MOVIE.value, - "title": "Test Movie", - "image": "http://example.com/movie.jpg", - }, - { - "media_id": "99999", # Not tracked - "source": Sources.TMDB.value, - "media_type": MediaTypes.MOVIE.value, - "title": "Unknown Movie", - "image": "http://example.com/unknown.jpg", - }, - ] - - # When section is "recommendations", completed items should be hidden - enriched_items = enrich_items_with_user_data( - self.request, raw_items, "recommendations" - ) - self.assertEqual(len(enriched_items), 1) - self.assertEqual(enriched_items[0]["item"]["media_id"], "99999") - - def test_hide_completed_recommendations_disabled(self): - """Test that completed items are shown when preference is disabled.""" - self.user.hide_completed_recommendations = False - self.user.save() - - raw_items = [ - { - "media_id": "238", # This is our completed movie - "source": Sources.TMDB.value, - "media_type": MediaTypes.MOVIE.value, - "title": "Test Movie", - "image": "http://example.com/movie.jpg", - }, - { - "media_id": "99999", - "source": Sources.TMDB.value, - "media_type": MediaTypes.MOVIE.value, - "title": "Unknown Movie", - "image": "http://example.com/unknown.jpg", - }, - ] - - # With preference disabled, all items should be returned - enriched_items = enrich_items_with_user_data( - self.request, raw_items, "recommendations" - ) - self.assertEqual(len(enriched_items), 2) diff --git a/src/app/tests/test_templatetags.py b/src/app/tests/test_templatetags.py index 5f82676ac..fe9c76bc0 100644 --- a/src/app/tests/test_templatetags.py +++ b/src/app/tests/test_templatetags.py @@ -356,22 +356,3 @@ def test_icon_media_types(self): self.assertTrue(len(inactive_result) > 0) except KeyError: self.fail(f"icon raised KeyError for {media_type}") - - def test_show_media_score(self): - """Test if we should show media rating or not.""" - # Create mock users - mock_user_show = MagicMock() - mock_user_show.hide_zero_rating = False - - mock_user_hide = MagicMock() - mock_user_hide.hide_zero_rating = True - - # With hide_zero_rating=False, show all non-None scores - self.assertTrue(app_tags.show_media_score(1, mock_user_show)) - self.assertTrue(app_tags.show_media_score(0, mock_user_show)) - self.assertFalse(app_tags.show_media_score(None, mock_user_show)) - - # With hide_zero_rating=True, hide zero scores - self.assertTrue(app_tags.show_media_score(1, mock_user_hide)) - self.assertFalse(app_tags.show_media_score(0, mock_user_hide)) - self.assertFalse(app_tags.show_media_score(None, mock_user_hide)) diff --git a/src/app/urls.py b/src/app/urls.py index 058c84852..651e18d74 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("", views.home, name="home"), + path("home/hide/", views.toggle_home_item, name="toggle_home_item"), path("medialist/", views.media_list, name="medialist"), path("search", views.media_search, name="search"), path( diff --git a/src/app/views.py b/src/app/views.py index 9542e1159..dd41ddd09 100644 --- a/src/app/views.py +++ b/src/app/views.py @@ -32,15 +32,33 @@ def home(request): """Home page with media items in progress.""" sort_by = request.user.update_preference("home_sort", request.GET.get("sort")) media_type_to_load = request.GET.get("load_media_type") + show_hidden = request.GET.get("filter") == "hidden" items_limit = 14 - list_by_type = BasicMedia.objects.get_in_progress( - request.user, - sort_by, - items_limit, - media_type_to_load, + hidden_item_ids = set( + request.user.home_hidden_items.values_list("id", flat=True), ) + if show_hidden: + # Show only hidden items — filter at DB level + list_by_type = BasicMedia.objects.get_in_progress( + request.user, + sort_by, + None, + media_type_to_load, + include_item_ids=hidden_item_ids, + ) + + else: + # Normal in-progress view — exclude hidden items at DB level + list_by_type = BasicMedia.objects.get_in_progress( + request.user, + sort_by, + items_limit, + media_type_to_load, + exclude_item_ids=hidden_item_ids if hidden_item_ids else None, + ) + # If this is an HTMX request to load more items for a specific media type if request.headers.get("HX-Request") and media_type_to_load: context = { @@ -53,10 +71,28 @@ def home(request): "current_sort": sort_by, "sort_choices": HomeSortChoices.choices, "items_limit": items_limit, + "hidden_count": len(hidden_item_ids), + "show_hidden": show_hidden, } return render(request, "app/home.html", context) +@require_POST +def toggle_home_item(request, item_id): + """Hide or unhide a single item from the home page.""" + try: + item = Item.objects.get(id=item_id) + except Item.DoesNotExist: + return HttpResponse(status=404) + + if request.user.home_hidden_items.filter(id=item_id).exists(): + request.user.home_hidden_items.remove(item) + else: + request.user.home_hidden_items.add(item) + + return HttpResponse(status=204) + + @require_POST def progress_edit(request, media_type, instance_id): """Increase or decrease the progress of a media item from home page.""" @@ -182,9 +218,7 @@ def media_search(request): # Enrich search results with user tracking data if data.get("results"): - data["results"] = helpers.enrich_items_with_user_data( - request, data["results"], "search" - ) + data["results"] = helpers.enrich_items_with_user_data(request, data["results"]) context = { "data": data, @@ -214,7 +248,8 @@ def media_details(request, source, media_type, media_id, title): # noqa: ARG001 if related_items: media_metadata["related"][section_name] = ( helpers.enrich_items_with_user_data( - request, related_items, section_name + request, + related_items, ) ) @@ -277,7 +312,6 @@ def season_details(request, source, media_id, title, season_number): # noqa: AR helpers.enrich_items_with_user_data( request, related_items, - section_name, ) ) diff --git a/src/config/settings.py b/src/config/settings.py index 0314247b7..d3cbe2586 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -263,9 +263,6 @@ def secret(key, default=undefined, **kwargs): "celery.utils.functional": { "level": "WARNING", }, - "fakeredis": { - "level": "WARNING", - }, }, "formatters": { "verbose": { @@ -462,7 +459,6 @@ def secret(key, default=undefined, **kwargs): ), ) - TESTING = False HEALTHCHECK_CELERY_PING_TIMEOUT = config( diff --git a/src/integrations/imports/steam.py b/src/integrations/imports/steam.py index 0beb623e0..ac27aa107 100644 --- a/src/integrations/imports/steam.py +++ b/src/integrations/imports/steam.py @@ -212,23 +212,6 @@ def _process_game(self, game_data): self.bulk_media[MediaTypes.GAME.value].append(game) - except services.ProviderAPIError as e: - msg = str(e).lower() - is_not_found = "game with id" in msg and "not found" in msg - if not is_not_found: - # still raise all other errors - raise - - logger.debug( - "Skipping Steam game %s (appid: %s) - IGDB not found: %s", - name, - appid, - e, - ) - self.warnings.append( - f"{name} ({appid}): Couldn't find a match in {Sources.IGDB.label}" - ) - except (ValueError, KeyError, TypeError) as e: logger.warning("Failed to process Steam game %s (%s): %s", name, appid, e) self.warnings.append(f"{name} ({appid}): {e!s}") @@ -256,30 +239,42 @@ def _determine_game_status(self, playtime_forever, playtime_2weeks): def _match_with_igdb(self, game_name, steam_appid): """Try to match Steam game with IGDB using External Game endpoint.""" - # Try to find IGDB game by Steam App ID using external_game endpoint + try: + # Try to find IGDB game by Steam App ID using external_game endpoint - igdb_game_id = external_game(steam_appid, ExternalGameSource.STEAM) + igdb_game_id = external_game(steam_appid, ExternalGameSource.STEAM) - if not igdb_game_id: - return None + if igdb_game_id: + # Get the game details using the IGDB ID + game_details = services.get_media_metadata( + MediaTypes.GAME.value, + str(igdb_game_id), + Sources.IGDB.value, + ) - # Get the game details using the IGDB ID - game_details = services.get_media_metadata( - MediaTypes.GAME.value, - str(igdb_game_id), - Sources.IGDB.value, - ) + if game_details: + logger.debug( + "Matched Steam game %s (appid: %s) with IGDB ID %s " + "via external_game", + game_name, + steam_appid, + igdb_game_id, + ) + return { + "media_id": igdb_game_id, + "source": Sources.IGDB.value, + "media_type": MediaTypes.GAME.value, + "title": game_details.get("title", game_name), + "image": game_details["image"], + } + + except (ValueError, KeyError, TypeError, services.ProviderAPIError) as e: + logger.debug( + "Failed to match Steam game %s (appid: %s) with IGDB " + "via external_game: %s", + game_name, + steam_appid, + e, + ) - logger.debug( - "Matched Steam game %s (appid: %s) with IGDB ID %s via external_game", - game_name, - steam_appid, - igdb_game_id, - ) - return { - "media_id": igdb_game_id, - "source": Sources.IGDB.value, - "media_type": MediaTypes.GAME.value, - "title": game_details.get("title", game_name), - "image": game_details["image"], - } + return None diff --git a/src/static/css/main.css b/src/static/css/main.css index 32596ec9a..bec43bc1c 100644 --- a/src/static/css/main.css +++ b/src/static/css/main.css @@ -1626,9 +1626,6 @@ .text-pretty { text-wrap: pretty; } - .break-words { - overflow-wrap: break-word; - } .whitespace-nowrap { white-space: nowrap; } diff --git a/src/static/js/searchShortcut.js b/src/static/js/searchShortcut.js deleted file mode 100644 index 225859aeb..000000000 --- a/src/static/js/searchShortcut.js +++ /dev/null @@ -1,19 +0,0 @@ -// Global keyboard shortcut for search -document.addEventListener('keydown', (e) => { - // Ignore if typing in an input, textarea, or contenteditable - const activeEl = document.activeElement; - const isTyping = activeEl.tagName === 'INPUT' || - activeEl.tagName === 'TEXTAREA' || - activeEl.isContentEditable; - - if (isTyping) return; - - if (e.key === '/') { - e.preventDefault(); - const searchInput = document.getElementById('global-search'); - if (searchInput) { - searchInput.focus(); - searchInput.select(); - } - } -}); diff --git a/src/templates/app/components/home_grid.html b/src/templates/app/components/home_grid.html index 4e03cd7fb..f757f7fed 100644 --- a/src/templates/app/components/home_grid.html +++ b/src/templates/app/components/home_grid.html @@ -1,7 +1,7 @@ {% load app_tags %} {% for media in media_list.items %} - diff --git a/src/templates/app/components/media_card.html b/src/templates/app/components/media_card.html index 2062dc606..b36f17f05 100644 --- a/src/templates/app/components/media_card.html +++ b/src/templates/app/components/media_card.html @@ -46,7 +46,7 @@ {% endif %} - {% if media.score|show_media_score:user %} + {% if media.score is not None %}
{% include "app/icons/star.svg" with classes="w-4 h-4 text-yellow-400 mr-1 fill-current" %} {{ media.formatted_score }} diff --git a/src/templates/app/home.html b/src/templates/app/home.html index e82a3f8db..781ffbe6e 100644 --- a/src/templates/app/home.html +++ b/src/templates/app/home.html @@ -7,13 +7,13 @@ {% block content %} +
{% for media_type, media_list in list_by_type.items %}

{{ media_type|media_type_readable_plural }}

{% include "app/components/home_grid.html" %}
- {% if media_list.total > items_limit %} + {% if not show_hidden and media_list.total > items_limit %}
diff --git a/src/templates/app/icons/alarm-clock.svg b/src/templates/app/icons/alarm-clock.svg index c0a5ede2e..a7e1df790 100644 --- a/src/templates/app/icons/alarm-clock.svg +++ b/src/templates/app/icons/alarm-clock.svg @@ -1,4 +1,6 @@ - - - diff --git a/src/templates/app/icons/circle-plus.svg b/src/templates/app/icons/circle-plus.svg index a30e3decd..ef912a93b 100644 --- a/src/templates/app/icons/circle-plus.svg +++ b/src/templates/app/icons/circle-plus.svg @@ -1,4 +1,6 @@ Cast {% for name, related_items in media.related.items %} {% if related_items %}
-

- {% if name == "dlcs" %} - DLCs - {% else %} - {{ name|no_underscore|title }} - {% endif %} -

+

{{ name|no_underscore|title }}

{% for result in related_items %} {# Set active to highlight the current movie in a collection. Avoid TV media, since seasons have same ID as the parent show #} diff --git a/src/templates/app/statistics.html b/src/templates/app/statistics.html index 42df61a3f..9f83cabaa 100644 --- a/src/templates/app/statistics.html +++ b/src/templates/app/statistics.html @@ -379,7 +379,7 @@

{% if media.end_date %}{{ media.end_date|datetime_format:user }}{% endif %}

- {% if media.score|show_media_score:user %} + {% if media.score is not None %}
{% include "app/icons/star.svg" with classes="w-4 h-4 mr-1 fill-current" %} {{ media.formatted_score }} diff --git a/src/templates/base.html b/src/templates/base.html index 47bae3aeb..3ed8ff1a7 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -225,7 +225,6 @@

Yamtrack

{# Search input #} Yamtrack - diff --git a/src/templates/users/import_data.html b/src/templates/users/import_data.html index d2ff52c2e..36147f40a 100644 --- a/src/templates/users/import_data.html +++ b/src/templates/users/import_data.html @@ -489,7 +489,7 @@

Import History

x-text="showDetails ? 'Hide traceback' : 'Show traceback'">Show traceback {% endif %} -
{% for error in result.errors.splitlines %}
{{ error }}
{% endfor %} diff --git a/src/templates/users/preferences.html b/src/templates/users/preferences.html index 82fe1ded6..b8872c9b6 100644 --- a/src/templates/users/preferences.html +++ b/src/templates/users/preferences.html @@ -85,44 +85,23 @@

Preferences

- {# Hide Completed Recommendations #} + {# Special Episodes #}
- {% include "app/icons/circle-check.svg" with classes="w-5 h-5 mr-2" %} - Hide completed media in recommendations + {% include "app/icons/star.svg" with classes="w-5 h-5 mr-2" %} + Show Special Episodes in In Progress

- Hide media you've already completed from the recommendations section on detail pages. + Display special episodes (Season 0) in the "In Progress" section on the home page.

-
-
- - {# Hide Zero Ratings #} -
-
-
-
- {% include "app/icons/star.svg" with classes="w-5 h-5 mr-2" %} - Hide zero ratings -
-

Hide the rating badge from media cards when the rating is zero.

-
- diff --git a/src/users/migrations/0048_add_hide_completed_recommendations.py b/src/users/migrations/0048_add_hide_completed_recommendations.py deleted file mode 100644 index 5ea22f707..000000000 --- a/src/users/migrations/0048_add_hide_completed_recommendations.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django on 2026-02-08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0047_set_progress_bar_true'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='hide_completed_recommendations', - field=models.BooleanField(default=False, help_text='Hide completed media in recommendations'), - ), - ] diff --git a/src/users/migrations/0048_user_show_home_progress.py b/src/users/migrations/0048_user_show_home_progress.py new file mode 100644 index 000000000..7222cc1e6 --- /dev/null +++ b/src/users/migrations/0048_user_show_home_progress.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.11 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration to add home_hidden_items M2M field.""" + + dependencies = [ + ("users", "0047_set_progress_bar_true"), + ("app", "0055_alter_item_media_type"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="home_hidden_items", + field=models.ManyToManyField( + blank=True, + help_text="Items hidden from the home page", + related_name="hidden_from_home_by", + to="app.item", + ), + ), + ] diff --git a/src/users/migrations/0049_add_hide_zero_rating.py b/src/users/migrations/0049_add_hide_zero_rating.py deleted file mode 100644 index 9fb803ac1..000000000 --- a/src/users/migrations/0049_add_hide_zero_rating.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django on 2026-02-08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0048_add_hide_completed_recommendations'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='hide_zero_rating', - field=models.BooleanField(default=False, help_text='Hide zero ratings from media cards'), - ), - ] diff --git a/src/users/migrations/0049_user_show_special_episodes.py b/src/users/migrations/0049_user_show_special_episodes.py new file mode 100644 index 000000000..2a2dd7009 --- /dev/null +++ b/src/users/migrations/0049_user_show_special_episodes.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration for show_special_episodes user preference.""" + + dependencies = [ + ("users", "0048_user_show_home_progress"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="show_special_episodes", + field=models.BooleanField( + default=True, + help_text="Show special episodes in In Progress", + ), + ), + ] diff --git a/src/users/models.py b/src/users/models.py index cd18e007b..9ef57f9a9 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -318,16 +318,18 @@ class User(AbstractUser): help_text="Show progress bar", ) - # Hide completed recommendations - hide_completed_recommendations = models.BooleanField( - default=False, - help_text="Hide completed media in recommendations", + # Items hidden from home page + home_hidden_items = models.ManyToManyField( + Item, + related_name="hidden_from_home_by", + blank=True, + help_text="Items hidden from the home page", ) - # Hide zero ratings - hide_zero_rating = models.BooleanField( - default=False, - help_text="Hide zero ratings from media cards", + # Special Episodes preference + show_special_episodes = models.BooleanField( + default=True, + help_text="Show special episodes in In Progress", ) # Watch provider region diff --git a/src/users/views.py b/src/users/views.py index c3e45805b..cc1901664 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -241,10 +241,7 @@ def preferences(request): QuickWatchDateChoices.CURRENT_DATE, ) request.user.progress_bar = "progress_bar" in request.POST - request.user.hide_completed_recommendations = ( - "hide_completed_recommendations" in request.POST - ) - request.user.hide_zero_rating = "hide_zero_rating" in request.POST + request.user.show_special_episodes = "show_special_episodes" in request.POST request.user.date_format = request.POST.get( "date_format", DateFormatChoices.ISO,