From 22de8089b72aba9270782232b3692e47151a6a06 Mon Sep 17 00:00:00 2001 From: Pascal Repond Date: Sat, 17 Jan 2026 21:38:55 +0100 Subject: [PATCH] feat: add tags system and TMDB import integration - Add Tag model with M2M relationship to Media - Add tag filtering and search functionality - Integrate TMDB API for importing movie/TV metadata - Add TMDB search page with poster download support - Refactor chip input JS to support both contributors and tags - Update fixtures with sample tags data --- README.md | 1 + docker/.env.example | 4 + docker/docker-compose.prod.yml | 2 + src/config/settings.py | 6 + src/core/admin.py | 3 +- src/core/filters.py | 30 +- src/core/fixtures/sample_data.json | 125 +++++++- src/core/forms.py | 5 +- ...ent_options_alter_agent_name_media_tags.py | 111 +++++++ src/core/models.py | 33 +- src/core/queries.py | 18 +- src/core/services/__init__.py | 1 + src/core/services/tmdb.py | 284 ++++++++++++++++++ src/core/urls.py | 4 + src/core/views.py | 283 +++++++++++++++-- src/static/js/base.js | 40 +++ src/static/js/media_edit.js | 266 +++++++++------- src/templates/base/media_detail.html | 13 +- src/templates/base/media_edit.html | 100 +++++- src/templates/base/media_import.html | 66 ++++ src/templates/base/media_index.html | 89 ++---- .../partials/filters/filter_badge.html | 18 ++ .../media_items/media_contributors.html | 5 +- .../partials/media_items/media_item.html | 6 + .../partials/media_items/media_tags.html | 9 + .../partials/navigation/filters_drawer.html | 20 +- src/templates/partials/tags/tag_chip.html | 10 + .../partials/tags/tag_suggestions.html | 19 ++ .../partials/tmdb/tmdb_suggestions.html | 48 +++ src/tests/core/test_mediaform_htmx.py | 2 +- src/tests/core/test_models.py | 61 ++++ src/tests/core/test_tmdb.py | 92 ++++++ 32 files changed, 1534 insertions(+), 240 deletions(-) create mode 100644 src/core/migrations/0011_tag_alter_agent_options_alter_agent_name_media_tags.py create mode 100644 src/core/services/__init__.py create mode 100644 src/core/services/tmdb.py create mode 100644 src/templates/base/media_import.html create mode 100644 src/templates/partials/filters/filter_badge.html create mode 100644 src/templates/partials/media_items/media_tags.html create mode 100644 src/templates/partials/tags/tag_chip.html create mode 100644 src/templates/partials/tags/tag_suggestions.html create mode 100644 src/templates/partials/tmdb/tmdb_suggestions.html create mode 100644 src/tests/core/test_tmdb.py diff --git a/README.md b/README.md index b960bd2..de79716 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ A Django application to track and rate the media I consume: movies, TV shows, bo - `SECRET_KEY`: Generate with `python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"` - `ALLOWED_HOSTS`: Add your domain and IP (e.g., `datakult.example.com,192.168.1.100,localhost`) - `DJANGO_SUPERUSER_PASSWORD`: Use a secure password + - `TMDB_API_KEY`: If you want to be able to import metadata from TMDB 4. Start the application: ```bash diff --git a/docker/.env.example b/docker/.env.example index 24c1b6e..c7963e3 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -15,3 +15,7 @@ ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_SUPERUSER_USERNAME=admin DJANGO_SUPERUSER_EMAIL=admin@example.com DJANGO_SUPERUSER_PASSWORD=change-this-password + +# API keys (optional - for importing metadata from TMDB) +# Leave empty to disable TMDB integration +TMDB_API_KEY= diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 7cceaeb..e128fca 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -28,4 +28,6 @@ services: - DJANGO_SUPERUSER_USERNAME=${DJANGO_SUPERUSER_USERNAME:-admin} - DJANGO_SUPERUSER_EMAIL=${DJANGO_SUPERUSER_EMAIL:-admin@example.com} - DJANGO_SUPERUSER_PASSWORD=${DJANGO_SUPERUSER_PASSWORD:-admin} + # Optional - leave empty to disable TMDB integration + - TMDB_API_KEY=${TMDB_API_KEY:-} restart: unless-stopped diff --git a/src/config/settings.py b/src/config/settings.py index 5a19cfd..5c375c9 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -218,6 +218,12 @@ TAILWIND_APP_NAME = "theme" +# ============================================================================= +# External API Keys +# ============================================================================= + +TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "") + # ============================================================================= # Security Settings for Production (behind reverse proxy like Cloudflare Tunnel) # ============================================================================= diff --git a/src/core/admin.py b/src/core/admin.py index 1361f75..4799510 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin -from core.models import Agent, Media, SavedView +from core.models import Agent, Media, SavedView, Tag admin.site.register(Agent) admin.site.register(Media) admin.site.register(SavedView) +admin.site.register(Tag) diff --git a/src/core/filters.py b/src/core/filters.py index cdbfbde..dff6aff 100644 --- a/src/core/filters.py +++ b/src/core/filters.py @@ -6,7 +6,7 @@ from django.db.models import Q from django.utils.translation import gettext as _ -from .models import Agent, Media +from .models import Agent, Media, Tag def resolve_sorting(request): @@ -27,6 +27,7 @@ def extract_filters(request): """Extract filter parameters from request and return filters dict.""" filters = { "contributor": request.GET.get("contributor", ""), + "tag": request.GET.get("tag", ""), "type": request.GET.getlist("type"), "status": request.GET.getlist("status"), "score": request.GET.getlist("score"), @@ -79,15 +80,25 @@ def get_field_choices(): } +def _apply_related_filter(queryset, model, pk_value, filter_field): + """Apply a filter based on a related model lookup.""" + instance = None + if pk_value: + with contextlib.suppress(ValueError, TypeError): + instance = model.objects.filter(pk=pk_value).first() + if instance: + queryset = queryset.filter(**{filter_field: instance}) + return queryset, instance + + def apply_contributor_filter(queryset, contributor_id): """Apply contributor filter to queryset and return (queryset, contributor).""" + return _apply_related_filter(queryset, Agent, contributor_id, "contributors") + - contributor = None - if contributor_id: - contributor = Agent.objects.filter(pk=contributor_id).first() - if contributor: - queryset = queryset.filter(contributors=contributor) - return queryset, contributor +def apply_tag_filter(queryset, tag_id): + """Apply tag filter to queryset and return (queryset, tag).""" + return _apply_related_filter(queryset, Tag, tag_id, "tags") def apply_type_filter(queryset, media_types): @@ -143,10 +154,11 @@ def apply_date_and_content_filters(queryset, filters): def apply_filters(queryset, filters): - """Apply filters to a queryset and return (queryset, contributor).""" + """Apply filters to a queryset and return (queryset, contributor, tag).""" queryset, contributor = apply_contributor_filter(queryset, filters["contributor"]) + queryset, tag = apply_tag_filter(queryset, filters["tag"]) queryset = apply_type_filter(queryset, filters["type"]) queryset = apply_status_filter(queryset, filters["status"]) queryset = apply_score_filter(queryset, filters["score"]) queryset = apply_date_and_content_filters(queryset, filters) - return queryset, contributor + return queryset, contributor, tag diff --git a/src/core/fixtures/sample_data.json b/src/core/fixtures/sample_data.json index d44d2e0..bafb061 100644 --- a/src/core/fixtures/sample_data.json +++ b/src/core/fixtures/sample_data.json @@ -1,4 +1,58 @@ [ + { + "model": "core.tag", + "pk": 1, + "fields": { + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "name": "Science-Fiction" + } + }, + { + "model": "core.tag", + "pk": 2, + "fields": { + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "name": "Animation" + } + }, + { + "model": "core.tag", + "pk": 3, + "fields": { + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "name": "Thriller" + } + }, + { + "model": "core.tag", + "pk": 4, + "fields": { + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "name": "Fantasy" + } + }, + { + "model": "core.tag", + "pk": 5, + "fields": { + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "name": "Action-RPG" + } + }, + { + "model": "core.tag", + "pk": 6, + "fields": { + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "name": "Electronic" + } + }, { "model": "core.agent", "pk": 1, @@ -140,6 +194,7 @@ "score": 9, "review_date": "2021-10", "contributors": [2], + "tags": [1], "cover": "covers/cover_01.jpg" } }, @@ -158,6 +213,7 @@ "score": 10, "review_date": "2020", "contributors": [3], + "tags": [1], "cover": "covers/cover_02.jpg" } }, @@ -176,6 +232,7 @@ "score": 8, "review_date": "2014-11-15", "contributors": [1], + "tags": [1], "cover": "covers/cover_03.jpg" } }, @@ -194,6 +251,7 @@ "score": 10, "review_date": "2015", "contributors": [4], + "tags": [2, 4], "cover": "covers/cover_04.jpg" } }, @@ -211,7 +269,8 @@ "review": "", "score": null, "review_date": null, - "contributors": [5] + "contributors": [5], + "tags": [1, 5] } }, { @@ -229,6 +288,7 @@ "score": 9, "review_date": "2018", "contributors": [6], + "tags": [6], "cover": "covers/cover_06.jpg" } }, @@ -247,6 +307,7 @@ "score": 10, "review_date": "2020", "contributors": [], + "tags": [3], "cover": "covers/cover_07.jpg" } }, @@ -264,7 +325,8 @@ "review": "A sequel that's even more **epic** than the first installment. The action sequences are *breathtaking*, and the character development reaches new heights. Villeneuve has created something truly special here.", "score": 9, "review_date": "2024-03", - "contributors": [2] + "contributors": [2], + "tags": [1] } }, { @@ -282,6 +344,7 @@ "score": 9, "review_date": "2018", "contributors": [7], + "tags": [5], "cover": "covers/cover_09.jpg" } }, @@ -300,6 +363,7 @@ "score": 9, "review_date": "2019", "contributors": [8], + "tags": [1], "cover": "covers/cover_10.jpg" } }, @@ -318,6 +382,7 @@ "score": 9, "review_date": "2016", "contributors": [9], + "tags": [3], "cover": "covers/cover_11.jpg" } }, @@ -335,7 +400,8 @@ "review": "A **brilliant** social thriller that blends genres masterfully. Bong Joon-ho's commentary on class struggle is *devastatingly effective*, shifting from comedy to horror with seamless precision. The film's structure is **perfect**, with each act building tension until the explosive finale.", "score": 10, "review_date": "2020-02", - "contributors": [10] + "contributors": [10], + "tags": [3] } }, { @@ -352,7 +418,8 @@ "review": "An **ambitious album** that celebrates analog music and live instrumentation. Daft Punk took a bold departure from their electronic roots, collaborating with legendary musicians. *Get Lucky* became an instant classic.", "score": 8, "review_date": "2013", - "contributors": [11] + "contributors": [11], + "tags": [6] } }, { @@ -370,6 +437,7 @@ "score": null, "review_date": null, "contributors": [12], + "tags": [4, 5], "cover": "covers/cover_14.jpg" } }, @@ -388,6 +456,7 @@ "score": 10, "review_date": "2021", "contributors": [13], + "tags": [4], "cover": "covers/cover_15.jpg" } }, @@ -423,6 +492,7 @@ "score": 9, "review_date": "2017", "contributors": [12], + "tags": [4, 5], "cover": "covers/cover_17.jpg" } }, @@ -441,6 +511,7 @@ "score": 9, "review_date": "2014", "contributors": [4], + "tags": [2, 4], "cover": "covers/cover_18.jpg" } }, @@ -477,6 +548,7 @@ "score": 9, "review_date": "2019", "contributors": [6], + "tags": [6], "cover": "covers/cover_20.jpg" } }, @@ -495,6 +567,7 @@ "score": null, "review_date": null, "contributors": [13], + "tags": [4], "cover": "covers/cover_21.jpg" } }, @@ -512,7 +585,8 @@ "review": "", "score": null, "review_date": null, - "contributors": [10] + "contributors": [10], + "tags": [3] } }, { @@ -530,7 +604,48 @@ "score": 6, "review_date": "2016", "contributors": [5], + "tags": [5], "cover": "covers/cover_23.jpg" } + }, + { + "model": "core.savedview", + "pk": 1, + "fields": { + "user": 1, + "name": "Movies to watch", + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T10:00:00Z", + "filter_types": ["FILM"], + "filter_statuses": ["PLANNED"], + "filter_scores": [], + "filter_contributor_id": null, + "filter_review_from": "", + "filter_review_to": "", + "filter_has_review": "", + "filter_has_cover": "", + "sort": "title", + "view_mode": "list" + } + }, + { + "model": "core.savedview", + "pk": 2, + "fields": { + "user": 1, + "name": "Faves", + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T10:00:00Z", + "filter_types": [], + "filter_statuses": ["COMPLETED"], + "filter_scores": [9, 10], + "filter_contributor_id": null, + "filter_review_from": "", + "filter_review_to": "", + "filter_has_review": "yes", + "filter_has_cover": "", + "sort": "-score", + "view_mode": "grid" + } } ] diff --git a/src/core/forms.py b/src/core/forms.py index 13394b9..5bc8674 100644 --- a/src/core/forms.py +++ b/src/core/forms.py @@ -39,6 +39,7 @@ class Meta: fields = ( "title", "contributors", + "tags", "media_type", "external_uri", "status", @@ -76,8 +77,8 @@ def __init__(self, *args, **kwargs): # Add HTMX attributes for dynamic validation validation_url = reverse("media_validate_field") for field_name, field in self.fields.items(): - # Do not add dynamic validation on file or M2M fields (cover, contributors) - if field_name in ["cover", "contributors"]: + # Do not add dynamic validation on file or M2M fields (cover, contributors, tags) + if field_name in ["cover", "contributors", "tags"]: continue field.widget.attrs.update( { diff --git a/src/core/migrations/0011_tag_alter_agent_options_alter_agent_name_media_tags.py b/src/core/migrations/0011_tag_alter_agent_options_alter_agent_name_media_tags.py new file mode 100644 index 0000000..44fb47c --- /dev/null +++ b/src/core/migrations/0011_tag_alter_agent_options_alter_agent_name_media_tags.py @@ -0,0 +1,111 @@ +# Generated by Django 6.0.1 on 2026-01-17 14:16 + +import django.utils.timezone +from django.db import migrations, models +from django.db.models import Count + + +def clean_agent_names_forward(apps, schema_editor): + """ + Clean Agent.name values before adding max_length=100 and unique constraint. + + This function: + 1. Truncates names longer than 100 characters + 2. Resolves duplicate names by appending a counter suffix (_1, _2, etc.) + + For duplicates, the Agent with the lowest ID keeps the original name, + and subsequent duplicates get renamed with a suffix. + """ + Agent = apps.get_model("core", "Agent") + db_alias = schema_editor.connection.alias + max_length = 100 + + # Step 1: Truncate names longer than max_length + for agent in Agent.objects.using(db_alias).all(): + if len(agent.name) > max_length: + agent.name = agent.name[:max_length] + agent.save(update_fields=["name"]) + + # Step 2: Find and resolve duplicate names (including those created by truncation) + duplicate_names = list( + Agent.objects.using(db_alias) + .values("name") + .annotate(count=Count("id")) + .filter(count__gt=1) + .values_list("name", flat=True) + ) + + for name in duplicate_names: + # Get all agents with this name, ordered by ID (oldest first) + agents = list(Agent.objects.using(db_alias).filter(name=name).order_by("id")) + + if len(agents) <= 1: + continue + + # The first agent (lowest ID) keeps the original name + # Subsequent agents get renamed with a suffix + for idx, agent in enumerate(agents[1:], start=1): + # Generate a unique name with suffix + suffix = f"_{idx}" + # Ensure the new name fits within max_length + base_name = name[: max_length - len(suffix)] + new_name = f"{base_name}{suffix}" + + # Handle edge case: if new_name already exists, increment counter + counter = idx + while Agent.objects.using(db_alias).filter(name=new_name).exists(): + counter += 1 + suffix = f"_{counter}" + base_name = name[: max_length - len(suffix)] + new_name = f"{base_name}{suffix}" + + agent.name = new_name + agent.save(update_fields=["name"]) + + +def reverse_noop(_apps, _schema_editor): + """ + Reverse operation is a no-op. + + We cannot restore deleted duplicates or their original relations. + """ + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0010_agent_updated_at_media_updated_at_savedview"), + ] + + operations = [ + migrations.CreateModel( + name="Tag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=100, unique=True, verbose_name="Name")), + ], + options={ + "verbose_name": "Tag", + "verbose_name_plural": "Tags", + }, + ), + migrations.AlterModelOptions( + name="agent", + options={"verbose_name": "Agent", "verbose_name_plural": "Agents"}, + ), + migrations.RunPython( + clean_agent_names_forward, + reverse_code=reverse_noop, + ), + migrations.AlterField( + model_name="agent", + name="name", + field=models.CharField(max_length=100, unique=True, verbose_name="Name"), + ), + migrations.AddField( + model_name="media", + name="tags", + field=models.ManyToManyField(blank=True, related_name="media", to="core.tag", verbose_name="Tags"), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index f59a610..c8350b3 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -102,9 +102,34 @@ class Agent(models.Model): name = models.CharField( verbose_name=_("Name"), blank=False, - max_length=255, + max_length=100, + unique=True, + ) + + class Meta: + verbose_name = _("Agent") + verbose_name_plural = _("Agents") + + def __str__(self): + return self.name + + +class Tag(models.Model): + """Model for tags/genres that can be applied to media.""" + + created_at = models.DateTimeField(default=timezone.now, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + name = models.CharField( + verbose_name=_("Name"), + blank=False, + max_length=100, + unique=True, ) + class Meta: + verbose_name = _("Tag") + verbose_name_plural = _("Tags") + def __str__(self): return self.name @@ -126,6 +151,12 @@ class Media(models.Model): blank=True, related_name="media", ) + tags = models.ManyToManyField( + Tag, + verbose_name=_("Tags"), + blank=True, + related_name="media", + ) media_type = models.CharField( verbose_name=_("Media type"), null=False, diff --git a/src/core/queries.py b/src/core/queries.py index 737f432..1e1455f 100644 --- a/src/core/queries.py +++ b/src/core/queries.py @@ -9,7 +9,12 @@ def build_search_queryset(query): """Build a filtered queryset based on search query.""" - q_objects = Q(title__icontains=query) | Q(contributors__name__icontains=query) | Q(review__icontains=query) + q_objects = ( + Q(title__icontains=query) + | Q(contributors__name__icontains=query) + | Q(review__icontains=query) + | Q(tags__name__icontains=query) + ) # Try to parse query as a year (integer) try: @@ -19,7 +24,7 @@ def build_search_queryset(query): # Not a valid integer, skip year filtering pass - return Media.objects.filter(q_objects).distinct() + return Media.objects.filter(q_objects).prefetch_related("tags", "contributors").distinct() def build_media_context(request): @@ -35,10 +40,14 @@ def build_media_context(request): search_query = request.GET.get("search", "").strip() # Build queryset based on whether it's a search or not - queryset = build_search_queryset(search_query) if search_query else Media.objects.all() + queryset = ( + build_search_queryset(search_query) + if search_query + else Media.objects.all().prefetch_related("tags", "contributors") + ) # Apply filters and sorting - queryset, contributor = apply_filters(queryset, filters) + queryset, contributor, tag = apply_filters(queryset, filters) queryset = queryset.order_by(sort) # Pagination: 20 items per page @@ -56,6 +65,7 @@ def build_media_context(request): "sort_field": sort_field, "sort": sort, "contributor": contributor, + "tag": tag, "filters": filters, "saved_views": saved_views, **get_field_choices(), diff --git a/src/core/services/__init__.py b/src/core/services/__init__.py new file mode 100644 index 0000000..63a2a9c --- /dev/null +++ b/src/core/services/__init__.py @@ -0,0 +1 @@ +# External API services diff --git a/src/core/services/tmdb.py b/src/core/services/tmdb.py new file mode 100644 index 0000000..173b6f6 --- /dev/null +++ b/src/core/services/tmdb.py @@ -0,0 +1,284 @@ +""" +TMDB API client for fetching movie and TV show metadata. + +API Documentation: https://developer.themoviedb.org/docs +""" + +import ipaddress +import logging +import re +import socket +from dataclasses import dataclass +from typing import Literal +from urllib.parse import urlencode, urljoin, urlparse + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +TMDB_BASE_URL = "https://api.themoviedb.org/3/" +TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/" + +# Allowed hosts for TMDB image downloads (SSRF protection) +TMDB_ALLOWED_IMAGE_HOSTS = frozenset({"image.tmdb.org"}) +# Pattern for valid TMDB poster paths (e.g., /t/p/w500/abc123.jpg) +TMDB_POSTER_PATH_PATTERN = re.compile(r"^/t/p/w\d+/[a-zA-Z0-9]+\.[a-z]{3,4}$") + +# Minimum query length for search +MIN_QUERY_LENGTH = 2 +# Minimum date string length for year extraction (YYYY) +MIN_DATE_LENGTH = 4 + + +class TMDBError(Exception): + """Exception raised when TMDB API key is missing or invalid.""" + + def __init__(self) -> None: + super().__init__("TMDB API key is required. Set TMDB_API_KEY in your environment.") + + +@dataclass +class TMDBResult: + """Represents a search result from TMDB.""" + + tmdb_id: int + title: str + original_title: str + year: int | None + overview: str + poster_path: str | None + media_type: Literal["movie", "tv"] + + @property + def poster_url(self) -> str | None: + """Returns the full URL for the poster image (w500 size).""" + if self.poster_path: + return f"{TMDB_IMAGE_BASE_URL}w500{self.poster_path}" + return None + + @property + def poster_url_small(self) -> str | None: + """Returns a smaller poster URL for thumbnails (w185 size).""" + if self.poster_path: + return f"{TMDB_IMAGE_BASE_URL}w185{self.poster_path}" + return None + + +class TMDBClient: + """Client for interacting with The Movie Database (TMDB) API.""" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or settings.TMDB_API_KEY + if not self.api_key: + raise TMDBError + + def _request(self, endpoint: str, params: dict | None = None) -> dict: + """Make a request to the TMDB API.""" + params = params or {} + params["api_key"] = self.api_key + + url = urljoin(TMDB_BASE_URL, endpoint) + full_url = f"{url}?{urlencode(params)}" + + try: + response = requests.get(full_url, timeout=10) + response.raise_for_status() + return response.json() + except requests.RequestException: + logger.exception("TMDB API request failed") + raise + + def search_multi(self, query: str, language: str = "fr-FR", page: int = 1) -> list[TMDBResult]: + """ + Search for movies and TV shows. + + Args: + query: The search query + language: Language for results (default: French) + page: Page number for pagination + + Returns: + List of TMDBResult objects + """ + if not query or len(query) < MIN_QUERY_LENGTH: + return [] + + data = self._request( + "search/multi", + {"query": query, "language": language, "page": page, "include_adult": False}, + ) + + results = [] + for item in data.get("results", []): + media_type = item.get("media_type") + if media_type not in ("movie", "tv"): + continue + + if media_type == "movie": + title = item.get("title", "") + original_title = item.get("original_title", "") + date_field = item.get("release_date", "") + else: + title = item.get("name", "") + original_title = item.get("original_name", "") + date_field = item.get("first_air_date", "") + + year = int(date_field[:4]) if date_field and len(date_field) >= MIN_DATE_LENGTH else None + + results.append( + TMDBResult( + tmdb_id=item.get("id"), + title=title, + original_title=original_title, + year=year, + overview=item.get("overview", ""), + poster_path=item.get("poster_path"), + media_type=media_type, + ) + ) + + return results + + def get_movie_details(self, movie_id: int, language: str = "fr-FR") -> dict: + """Get detailed information about a movie, including credits and production companies.""" + return self._request(f"movie/{movie_id}", {"language": language, "append_to_response": "credits"}) + + def get_tv_details(self, tv_id: int, language: str = "fr-FR") -> dict: + """Get detailed information about a TV show, including credits and production companies.""" + return self._request(f"tv/{tv_id}", {"language": language, "append_to_response": "credits"}) + + def get_full_details(self, tmdb_id: int, media_type: Literal["movie", "tv"], language: str = "fr-FR") -> dict: + """ + Get full details for a movie or TV show including contributors. + + Returns a dict with: + - title, original_title, year, overview + - directors: list of director names + - production_companies: list of company names + - poster_url: full URL for poster image + - tmdb_url: URL to TMDB page + """ + if media_type == "movie": + data = self.get_movie_details(tmdb_id, language) + title = data.get("title", "") + original_title = data.get("original_title", "") + date_field = data.get("release_date", "") + # Get directors from crew + crew = data.get("credits", {}).get("crew", []) + directors = [p["name"] for p in crew if p.get("job") == "Director"] + else: + data = self.get_tv_details(tmdb_id, language) + title = data.get("name", "") + original_title = data.get("original_name", "") + date_field = data.get("first_air_date", "") + # For TV shows, get creators instead of directors + directors = [p["name"] for p in data.get("created_by", [])] + + year = int(date_field[:4]) if date_field and len(date_field) >= MIN_DATE_LENGTH else None + + # Get production companies + production_companies = [c["name"] for c in data.get("production_companies", [])[:2]] + + # Get genres + genres = [g["name"] for g in data.get("genres", [])] + + # Build poster URL + poster_path = data.get("poster_path") + poster_url = f"{TMDB_IMAGE_BASE_URL}w500{poster_path}" if poster_path else None + + # Build TMDB URL + tmdb_url = f"https://www.themoviedb.org/{media_type}/{tmdb_id}" + + return { + "title": title, + "original_title": original_title, + "year": year, + "overview": data.get("overview", ""), + "directors": directors, + "production_companies": production_companies, + "genres": genres, + "poster_url": poster_url, + "tmdb_url": tmdb_url, + "media_type": media_type, + } + + def _validate_poster_url(self, poster_url: str) -> bool: + """ + Validate that the poster URL is a legitimate TMDB image URL. + + Returns True if the URL is valid and safe to fetch, False otherwise. + """ + try: + parsed = urlparse(poster_url) + except ValueError: + logger.warning("Invalid URL format rejected: %s", poster_url) + return False + + # Validate scheme, host, and path pattern + if parsed.scheme != "https": + logger.warning("Non-HTTPS URL rejected: %s", poster_url) + return False + if parsed.hostname not in TMDB_ALLOWED_IMAGE_HOSTS: + logger.warning("URL with disallowed host rejected: %s", poster_url) + return False + if not TMDB_POSTER_PATH_PATTERN.match(parsed.path): + logger.warning("URL with invalid path pattern rejected: %s", poster_url) + return False + + # DNS resolution check: reject private/reserved IP ranges + return self._validate_resolved_ip(poster_url, parsed.hostname) + + def _validate_resolved_ip(self, poster_url: str, hostname: str) -> bool: + """Check that hostname does not resolve to private/reserved IP ranges.""" + try: + resolved_ips = socket.getaddrinfo(hostname, 443, proto=socket.IPPROTO_TCP) + for _, _, _, _, sockaddr in resolved_ips: + ip_str = sockaddr[0] + ip = ipaddress.ip_address(ip_str) + if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local: + logger.warning("URL resolving to private/reserved IP rejected: %s -> %s", poster_url, ip_str) + return False + except (socket.gaierror, ValueError) as e: + logger.warning("DNS resolution failed for URL %s: %s", poster_url, e) + return False + return True + + def download_poster(self, poster_url: str) -> bytes | None: + """ + Download poster image and return bytes. + + Validates the URL against TMDB allowlist and performs security checks + to prevent SSRF attacks before downloading. + """ + if not poster_url: + return None + + if not self._validate_poster_url(poster_url): + return None + + try: + response = requests.get(poster_url, timeout=15, allow_redirects=False) + response.raise_for_status() + except requests.RequestException: + logger.exception("Failed to download poster from %s", poster_url) + return None + + # Check for redirect responses (3xx status codes) + if response.is_redirect or response.is_permanent_redirect: + logger.warning("Redirect response rejected for URL: %s", poster_url) + return None + + return response.content + + +def get_tmdb_client() -> TMDBClient | None: + """ + Factory function to get a TMDB client instance. + + Returns None if the API key is not configured. + """ + if not settings.TMDB_API_KEY: + logger.warning("TMDB API key not configured") + return None + return TMDBClient() diff --git a/src/core/urls.py b/src/core/urls.py index 7f379bf..48807c1 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -6,12 +6,16 @@ urlpatterns = [ path("", views.index, name="home"), path("media/add/", views.media_edit, name="media_add"), + path("media/import/", views.media_import, name="media_import"), path("media//", views.media_detail, name="media_detail"), path("media//edit/", views.media_edit, name="media_edit"), path("media//delete/", views.media_delete, name="media_delete"), path("load-more/", views.load_more_media, name="load_more_media"), path("agents/search-htmx/", views.agent_search_htmx, name="agent_search_htmx"), path("agents/select-htmx/", views.agent_select_htmx, name="agent_select_htmx"), + path("tags/search-htmx/", views.tag_search_htmx, name="tag_search_htmx"), + path("tags/select-htmx/", views.tag_select_htmx, name="tag_select_htmx"), + path("tmdb-search/", views.tmdb_search_htmx, name="tmdb_search_htmx"), path("media/validate_field/", validate_media_field, name="media_validate_field"), path("media//review-full/", views.media_review_full_htmx, name="media_review_full_htmx"), path("media//review-clamped/", views.media_review_clamped_htmx, name="media_review_clamped_htmx"), diff --git a/src/core/views.py b/src/core/views.py index 8ea2a7f..5db7385 100644 --- a/src/core/views.py +++ b/src/core/views.py @@ -1,22 +1,34 @@ +import logging import tarfile import tempfile from pathlib import Path +import requests from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.files.base import ContentFile from django.core.management import call_command from django.core.management.base import CommandError +from django.db import IntegrityError from django.http import FileResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext as _ from partial_date import PartialDate from .forms import MediaForm -from .models import Agent, Media, SavedView +from .models import Agent, Media, SavedView, Tag from .queries import build_media_context +from .services.tmdb import get_tmdb_client from .utils import create_backup, delete_orphan_agents_by_ids +logger = logging.getLogger(__name__) + +# TMDB constants +DEFAULT_TMDB_LANGUAGE = "en-US" +MIN_SEARCH_QUERY_LENGTH = 2 +MAX_TMDB_RESULTS = 8 + @login_required def index(request): @@ -33,52 +45,210 @@ def media_detail(request, pk): return render(request, "base/media_detail.html", context) +MAX_NAME_LENGTH = 100 + + +def _get_or_create_safe(model_class, name): + """ + Safely get or create an object by name with validation. + + Returns (instance, error_message). If error_message is not None, + instance will be None. + """ + clean_name = name.strip()[:MAX_NAME_LENGTH] + + if not clean_name: + return None, None # Skip empty names silently + + # Try to find existing object first to avoid race conditions + existing = model_class.objects.filter(name=clean_name).first() + if existing: + return existing, None + + # Try to create, catching IntegrityError for race conditions + try: + obj, _created = model_class.objects.get_or_create(name=clean_name) + except IntegrityError: + # Race condition: object was created between filter() and get_or_create() + existing = model_class.objects.filter(name=clean_name).first() + if existing: + return existing, None + # Unexpected error + return None, f"Failed to create {model_class.__name__}: {clean_name}" + else: + return obj, None + + +def _process_new_contributors(post_data): + """Process new contributors from POST and return (modified POST data, errors).""" + new_contributor_names = post_data.getlist("new_contributors") + new_contributor_ids = [] + errors = [] + + for raw_name in new_contributor_names: + agent, error = _get_or_create_safe(Agent, raw_name) + if error: + errors.append(error) + elif agent: + new_contributor_ids.append(str(agent.pk)) + + # Create a mutable copy and merge contributors + post_data = post_data.copy() + existing_contributors = post_data.getlist("contributors") + post_data.setlist("contributors", existing_contributors + new_contributor_ids) + return post_data, errors + + +def _process_new_tags(post_data): + """Process new tags from POST and return (modified POST data, errors).""" + new_tag_names = post_data.getlist("new_tags") + new_tag_ids = [] + errors = [] + + for raw_name in new_tag_names: + tag, error = _get_or_create_safe(Tag, raw_name) + if error: + errors.append(error) + elif tag: + new_tag_ids.append(str(tag.pk)) + + # Merge with existing tags + existing_tags = post_data.getlist("tags") + post_data.setlist("tags", existing_tags + new_tag_ids) + return post_data, errors + + +def _handle_tmdb_poster(request, instance): + """Download and attach TMDB poster if provided.""" + tmdb_poster_url = request.POST.get("tmdb_poster_url") + if tmdb_poster_url and not request.FILES.get("cover"): + poster_bytes = _download_tmdb_poster(tmdb_poster_url) + if poster_bytes: + filename = f"{instance.title[:50].replace('/', '_')}.jpg" + instance.cover.save(filename, ContentFile(poster_bytes), save=False) + + +def _build_tmdb_initial_data(tmdb_data: dict, media_type: str, media=None) -> dict: + """Build form initial data from TMDB data, optionally merging with existing media.""" + initial_data = { + "title": tmdb_data.get("title", ""), + "pub_year": tmdb_data.get("year"), + "media_type": "FILM" if media_type == "movie" else "TV", + "external_uri": tmdb_data.get("tmdb_url", ""), + } + if media: + # Keep existing values for fields user may have customized + initial_data["status"] = media.status + initial_data["score"] = media.score + initial_data["review"] = media.review + initial_data["review_date"] = media.review_date + return initial_data + + @login_required def media_edit(request, pk=None): media = get_object_or_404(Media, pk=pk) if pk else None + tmdb_data = None + tmdb_contributors = [] + tmdb_tags = [] + if request.method == "POST": - before_contributor_ids = set() - if media is not None: - before_contributor_ids = set(media.contributors.values_list("pk", flat=True)) - # Handle new contributors first - new_contributor_names = request.POST.getlist("new_contributors") - new_contributor_ids = [] - for raw_name in new_contributor_names: - name = raw_name.strip() - if name: - agent, _created = Agent.objects.get_or_create(name=name) - new_contributor_ids.append(str(agent.pk)) - - # Create a mutable copy of POST data - post_data = request.POST.copy() - - # Add new contributor IDs to existing contributors - existing_contributors = post_data.getlist("contributors") - all_contributor_ids = existing_contributors + new_contributor_ids - post_data.setlist("contributors", all_contributor_ids) + before_contributor_ids = set(media.contributors.values_list("pk", flat=True)) if media else set() + post_data, contributor_errors = _process_new_contributors(request.POST) + post_data, tag_errors = _process_new_tags(post_data) + + # Report any errors from processing contributors/tags + for error in contributor_errors + tag_errors: + messages.error(request, error) form = MediaForm(post_data, request.FILES, instance=media) if form.is_valid(): - instance = form.save() - # Cleanup agents removed from this media that became orphans + instance = form.save(commit=False) + _handle_tmdb_poster(request, instance) + instance.save() + form.save_m2m() + + # Cleanup orphan agents after_contributor_ids = set(instance.contributors.values_list("pk", flat=True)) removed_ids = before_contributor_ids - after_contributor_ids if removed_ids: delete_orphan_agents_by_ids(removed_ids) - # Add success message - if media: - messages.success(request, _("'%(title)s' updated successfully") % {"title": instance.title}) - else: - messages.success(request, _("'%(title)s' created successfully") % {"title": instance.title}) - + msg_key = "'%(title)s' updated successfully" if media else "'%(title)s' created successfully" + messages.success(request, _(msg_key) % {"title": instance.title}) return redirect("media_detail", pk=instance.pk) else: - form = MediaForm(instance=media) - context = {"media": media, "form": form} + tmdb_id = request.GET.get("tmdb_id") + media_type = request.GET.get("media_type") + lang = request.GET.get("lang", DEFAULT_TMDB_LANGUAGE) + + if tmdb_id and media_type in ("movie", "tv"): + tmdb_data = _fetch_tmdb_data(tmdb_id, media_type, language=lang) + if tmdb_data: + initial_data = _build_tmdb_initial_data(tmdb_data, media_type, media) + form = MediaForm(initial=initial_data, instance=media) + + # Filter out TMDB contributors/tags that already exist on the media + existing_contributor_names = set() + existing_tag_names = set() + if media: + existing_contributor_names = {c.name.lower() for c in media.contributors.all()} + existing_tag_names = {t.name.lower() for t in media.tags.all()} + + tmdb_contributors = [ + name for name in tmdb_data.get("contributors", []) if name.lower() not in existing_contributor_names + ] + tmdb_tags = [name for name in tmdb_data.get("genres", []) if name.lower() not in existing_tag_names] + else: + form = MediaForm(instance=media) + else: + form = MediaForm(instance=media) + + context = { + "media": media, + "form": form, + "tmdb_data": tmdb_data, + "tmdb_contributors": tmdb_contributors, + "tmdb_tags": tmdb_tags, + } return render(request, "base/media_edit.html", context) +def _fetch_tmdb_data(tmdb_id: str, media_type: str, language: str = DEFAULT_TMDB_LANGUAGE) -> dict | None: + """Fetch TMDB data for pre-filling the form.""" + client = get_tmdb_client() + if not client: + return None + + try: + details = client.get_full_details(int(tmdb_id), media_type, language=language) + except (requests.RequestException, ValueError): + logger.exception("Failed to fetch TMDB data for %s/%s", media_type, tmdb_id) + return None + + # Combine directors and production companies + contributors = details.get("directors", []) + details.get("production_companies", []) + details["contributors"] = contributors + return details + + +def _download_tmdb_poster(poster_url: str) -> bytes | None: + """Download poster image from TMDB.""" + client = get_tmdb_client() + if not client: + return None + return client.download_poster(poster_url) + + +@login_required +def media_import(request): + """Display TMDB search page for importing media.""" + # Optional: if editing existing media, pass media_id to template + media_id = request.GET.get("media_id") + context = {"media_id": media_id} + return render(request, "base/media_import.html", context) + + @login_required def media_delete(request, pk): media = get_object_or_404(Media, pk=pk) @@ -126,6 +296,59 @@ def agent_select_htmx(request): ) +@login_required +def tag_search_htmx(request): + """HTMX view: search tags by name.""" + query = request.GET.get("q", "").strip() + tags = Tag.objects.filter(name__icontains=query).order_by("name")[:12] if query else [] + return render(request, "partials/tags/tag_suggestions.html", {"tags": tags}) + + +@login_required +def tag_select_htmx(request): + """Select an existing tag and return the chip.""" + tag_id = request.POST.get("id") + try: + tag = Tag.objects.get(pk=tag_id) + return render(request, "partials/tags/tag_chip.html", {"tag": tag}) + except Tag.DoesNotExist: + return render(request, "partials/tags/tag_chip.html", {"tag": None, "error": "Tag not found"}) + + +@login_required +def tmdb_search_htmx(request): + """HTMX view: search TMDB for movies and TV shows.""" + query = request.GET.get("q", "").strip() + media_id = request.GET.get("media_id") # For editing existing media + lang = request.GET.get("lang", DEFAULT_TMDB_LANGUAGE) + + base_context = {"results": [], "media_id": media_id, "lang": lang} + + if len(query) < MIN_SEARCH_QUERY_LENGTH: + return render(request, "partials/tmdb/tmdb_suggestions.html", base_context) + + client = get_tmdb_client() + if not client: + logger.warning("TMDB search attempted but API key not configured") + return render( + request, + "partials/tmdb/tmdb_suggestions.html", + {**base_context, "error": "TMDB API key not configured"}, + ) + + try: + results = client.search_multi(query, language=lang)[:MAX_TMDB_RESULTS] + except requests.RequestException: + logger.exception("TMDB search failed") + return render( + request, + "partials/tmdb/tmdb_suggestions.html", + {**base_context, "error": "Search failed"}, + ) + + return render(request, "partials/tmdb/tmdb_suggestions.html", {**base_context, "results": results}) + + @login_required def media_review_clamped_htmx(request, pk): """HTMX view: return clamped review for a media item (for table cell collapse).""" diff --git a/src/static/js/base.js b/src/static/js/base.js index c2f8adf..77daa4c 100644 --- a/src/static/js/base.js +++ b/src/static/js/base.js @@ -150,10 +150,50 @@ function registerServiceWorker() { } } +// MULTI-SELECT FILTER LABELS +// Update dropdown labels to show count of selected items +function initMultiSelectLabels() { + const filters = [ + { name: 'type', labelId: 'type-filter-label' }, + { name: 'status', labelId: 'status-filter-label' }, + { name: 'score', labelId: 'score-filter-label' } + ]; + + filters.forEach(filter => { + const label = document.getElementById(filter.labelId); + if (!label) return; + + // Find the dropdown container + const dropdown = label.closest('.dropdown'); + if (!dropdown) return; + + // Read localized strings from data attributes (with fallbacks) + const defaultText = label.dataset.defaultText || 'All'; + const selectedText = label.dataset.selectedText || 'selected'; + + const checkboxes = dropdown.querySelectorAll(`input[name="${filter.name}"]`); + + const updateLabel = () => { + const checkedCount = dropdown.querySelectorAll(`input[name="${filter.name}"]:checked`).length; + if (checkedCount === 0) { + label.textContent = defaultText; + } else { + label.textContent = `${checkedCount} ${selectedText}`; + } + }; + + // Add change listener to each checkbox + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', updateLabel); + }); + }); +} + // INITIALIZE ALL FEATURES ON DOM READY document.addEventListener('DOMContentLoaded', function() { initThemeSwitcher(); cleanUrlParameters(); initToastMessages(); registerServiceWorker(); + initMultiSelectLabels(); }); diff --git a/src/static/js/media_edit.js b/src/static/js/media_edit.js index 71cd926..8c4cd5a 100644 --- a/src/static/js/media_edit.js +++ b/src/static/js/media_edit.js @@ -1,4 +1,5 @@ -// Interactivity for media edit page: contributors, date picker, and delete confirmation +// Interactivity for media edit page: chips (contributors, tags) and date picker + document.addEventListener('DOMContentLoaded', () => { // Set up "Set to today" button for review date const setTodayBtn = document.getElementById('set-today-btn'); @@ -10,134 +11,187 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Contributor chips and autocomplete dropdown - const input = document.getElementById('contributor_search'); - const suggestions = document.getElementById('contributor-suggestions'); - const chips = document.getElementById('contributors-chips'); - - if (!input || !suggestions) return; - - const toggleSuggestionsVisibility = () => { - const hasContent = suggestions.innerHTML.trim().length > 0; - suggestions.classList.toggle('hidden', !hasContent); - }; - - // Show suggestions when input is focused - input.addEventListener('focus', () => { - if (suggestions.innerHTML.trim()) { - suggestions.classList.remove('hidden'); - } - }); - - // Hide suggestions shortly after input loses focus - input.addEventListener('blur', () => { - setTimeout(() => suggestions.classList.add('hidden'), 200); - }); - - const contributorAlreadyExists = (name) => { - const lower = name.trim().toLowerCase(); - - // Check existing chips (from database) - const existingChips = Array.from(chips?.querySelectorAll('span[data-id], span[data-name]') || []).some( - (chip) => (chip.dataset.name || chip.textContent || '').trim().toLowerCase() === lower, - ); - - // Check new contributors (created dynamically) - const newContributors = Array.from(document.querySelectorAll('input[name="new_contributors"]')).some( - (inp) => inp.value.trim().toLowerCase() === lower, - ); - - return existingChips || newContributors; - }; - - const addNewContributorChip = (name) => { - if (!name || contributorAlreadyExists(name)) return; + // Handle cover file input to clear TMDB poster when a file is selected + const coverInput = document.getElementById('id_cover'); + const tmdbPosterUrlInput = document.getElementById('tmdb-poster-url-input'); + const tmdbPosterPreview = document.getElementById('tmdb-poster-preview'); + + if (coverInput) { + coverInput.addEventListener('change', () => { + if (coverInput.files && coverInput.files.length > 0) { + // Clear the TMDB poster URL so the uploaded file takes precedence + if (tmdbPosterUrlInput) { + tmdbPosterUrlInput.value = ''; + } + // Update preview to show the selected file instead + if (tmdbPosterPreview) { + const file = coverInput.files[0]; + const reader = new FileReader(); + reader.onload = (e) => { + tmdbPosterPreview.src = e.target.result; + tmdbPosterPreview.alt = file.name; + }; + reader.readAsDataURL(file); + } + } + }); + } - const chip = document.createElement('span'); - chip.className = 'badge badge-lg badge-primary gap-2'; - chip.dataset.name = name; + // Store all chip inputs for HTMX event handling + const chipInputs = []; + + // Generic chip input handler with optional HTMX autocomplete support + const initChipInput = ({ inputId, containerId, suggestionsId, hiddenInputName, badgeClass }) => { + const input = document.getElementById(inputId); + const container = document.getElementById(containerId); + const suggestions = suggestionsId ? document.getElementById(suggestionsId) : null; + if (!input || !container) return null; + + const chipExists = (name) => { + const lower = name.trim().toLowerCase(); + return Array.from(container.querySelectorAll('.badge')).some((badge) => { + const badgeName = + badge.dataset.name || badge.querySelector('span')?.textContent || badge.textContent; + return (badgeName || '').trim().toLowerCase() === lower; + }); + }; + + const addChip = (name) => { + if (!name || chipExists(name)) return; + + const chip = document.createElement('span'); + chip.className = `badge badge-lg ${badgeClass} gap-2`; + chip.dataset.name = name; + + const text = document.createElement('span'); + text.textContent = name; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-ghost btn-xs btn-circle'; + btn.dataset.action = 'remove-chip'; + btn.textContent = '✕'; + + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = hiddenInputName; + hidden.value = name; + + chip.append(text, btn, hidden); + container.appendChild(chip); + }; + + input.addEventListener('keydown', (evt) => { + if (evt.key !== 'Enter') return; + evt.preventDefault(); + const name = input.value.trim(); + if (!name) return; + addChip(name); + input.value = ''; + // Clear suggestions if present + if (suggestions) { + suggestions.innerHTML = ''; + suggestions.classList.add('hidden'); + } + }); - const text = document.createElement('span'); - text.textContent = name; + // Delegate chip removal + container.addEventListener('click', (evt) => { + const btn = evt.target.closest('[data-action="remove-chip"]'); + if (!btn) return; + const chip = btn.closest('.badge'); + if (!chip) return; + + const inputRefId = chip.dataset.inputId; + const hiddenInput = inputRefId + ? document.getElementById(inputRefId) + : chip.querySelector('input[type="hidden"]'); + chip.remove(); + hiddenInput?.remove(); + }); - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'btn btn-ghost btn-xs btn-circle'; - btn.dataset.action = 'remove-chip'; - btn.textContent = '✕'; + // Autocomplete dropdown behavior (if suggestions element exists) + if (suggestions) { + input.addEventListener('focus', () => { + if (suggestions.innerHTML.trim()) suggestions.classList.remove('hidden'); + }); - const hidden = document.createElement('input'); - hidden.type = 'hidden'; - hidden.name = 'new_contributors'; - hidden.value = name; + input.addEventListener('blur', () => { + setTimeout(() => suggestions.classList.add('hidden'), 200); + }); + } - chip.append(text, btn, hidden); - chips?.appendChild(chip); + const instance = { input, container, suggestions, addChip, chipExists }; + chipInputs.push(instance); + return instance; }; - input.addEventListener('keydown', (evt) => { - if (evt.key !== 'Enter') return; - - const name = input.value.trim(); - if (!name) return; + // Initialize tags chip input with autocomplete + initChipInput({ + inputId: 'tag_search', + containerId: 'tags-chips', + suggestionsId: 'tag-suggestions', + hiddenInputName: 'new_tags', + badgeClass: 'badge-secondary', + }); - evt.preventDefault(); - addNewContributorChip(name); - input.value = ''; - suggestions.innerHTML = ''; - suggestions.classList.add('hidden'); + // Initialize contributors chip input with autocomplete + initChipInput({ + inputId: 'contributor_search', + containerId: 'contributors-chips', + suggestionsId: 'contributor-suggestions', + hiddenInputName: 'new_contributors', + badgeClass: 'badge-primary', }); - // Show/hide dropdown when htmx swaps new content into the suggestions box - if (window.htmx) { + // Single set of HTMX event handlers for all chip inputs + if (window.htmx && chipInputs.length > 0) { document.body.addEventListener('htmx:afterSwap', (evt) => { const target = evt.detail?.target || evt.target; - if (target === suggestions) { - toggleSuggestionsVisibility(); + for (const { suggestions } of chipInputs) { + if (suggestions && target === suggestions) { + suggestions.classList.toggle('hidden', !suggestions.innerHTML.trim()); + } } }); document.body.addEventListener('htmx:beforeRequest', (evt) => { const target = evt.detail?.target || evt.target; - if (target === suggestions) { - suggestions.classList.add('hidden'); + for (const { suggestions } of chipInputs) { + if (suggestions && target === suggestions) { + suggestions.classList.add('hidden'); + } } }); document.body.addEventListener('htmx:beforeSwap', (evt) => { const target = evt.detail?.target || evt.target; - if (target !== chips) return; - - const responseHtml = evt.detail?.serverResponse || evt.detail?.xhr?.responseText; - if (!responseHtml) return; - - const tmp = document.createElement('div'); - tmp.innerHTML = responseHtml; - const incomingChip = tmp.querySelector('span[data-id]'); - const incomingId = incomingChip?.dataset.id; - - if (incomingId && chips.querySelector(`span[data-id="${incomingId}"]`)) { - evt.detail.shouldSwap = false; - suggestions.classList.add('hidden'); - input.value = ''; + for (const { input, container, suggestions, chipExists } of chipInputs) { + if (target !== container) continue; + + const responseHtml = evt.detail?.serverResponse || evt.detail?.xhr?.responseText; + if (!responseHtml) continue; + + const tmp = document.createElement('div'); + tmp.innerHTML = responseHtml; + const incomingChip = tmp.querySelector('span[data-id]'); + const incomingId = incomingChip?.dataset.id; + + const incomingName = + incomingChip?.dataset.name || + incomingChip?.querySelector('span')?.textContent || + incomingChip?.textContent || + ''; + + const idExists = incomingId && container.querySelector(`span[data-id="${incomingId}"]`); + const nameExists = incomingName && chipExists(incomingName); + + if (idExists || nameExists) { + evt.detail.shouldSwap = false; + if (suggestions) suggestions.classList.add('hidden'); + input.value = ''; + } } }); } - - // Delegate chip removal so it also works for htmx-inserted chips - chips?.addEventListener('click', (evt) => { - const btn = evt.target.closest('[data-action="remove-chip"]'); - if (!btn) return; - - const chip = btn.closest('span'); - if (!chip) return; - - const inputId = chip.dataset.inputId; - const hiddenInput = inputId - ? document.getElementById(inputId) - : chip.querySelector('input[type="hidden"]') || document.querySelector(`input[name="contributors"][value="${chip.dataset.id}"]`); - - chip.remove(); - hiddenInput?.remove(); - }); }); diff --git a/src/templates/base/media_detail.html b/src/templates/base/media_detail.html index 98a7e83..2f11d0f 100644 --- a/src/templates/base/media_detail.html +++ b/src/templates/base/media_detail.html @@ -49,11 +49,16 @@

{# Contributors #} {% if media.contributors.all %} -
+
{% lucide "user" size="16" class="opacity-50" %} -
- {% include "partials/media_items/media_contributors.html" with use_htmx=False %} -
+ {% include "partials/media_items/media_contributors.html" with use_htmx=False %} +
+ {% endif %} + {# Tags #} + {% if media.tags.all %} +
+ {% lucide "tag" size="16" class="opacity-50" %} + {% include "partials/media_items/media_tags.html" %}
{% endif %} {# External link #} diff --git a/src/templates/base/media_edit.html b/src/templates/base/media_edit.html index 143fd0d..899fe83 100644 --- a/src/templates/base/media_edit.html +++ b/src/templates/base/media_edit.html @@ -37,10 +37,18 @@

{% endif %}

- +
+ + {% lucide "download" class="w-4 h-4" %} + TMDB + + +
{# Main content #}
@@ -53,7 +61,32 @@

- {{ form.cover }} + {# Show TMDB poster if available #} + {% if tmdb_data.poster_url %} +
+ {% translate 'Cover from TMDB' %} + +

+ {% if media.cover %} + {% translate "This will replace the current cover" %} + {% else %} + {% translate "Cover will be imported from TMDB" %} + {% endif %} +

+
+ {% translate "Upload a different image instead" %} +
{{ form.cover }}
+
+
+ {% else %} + {{ form.cover }} + {% endif %} @@ -77,9 +110,20 @@

{% include "partials/common/field_label.html" with label=_("Contributors") field=form.contributors %}
+ {# Existing contributors (from database) #} {% for contributor in media.contributors.all %} {% include "partials/contributors/contributor_chip.html" with agent=contributor %} {% endfor %} + {# TMDB contributors (pre-filled as new) #} + {% for name in tmdb_contributors %} + + {{ name }} + + + + {% endfor %}
-

+
+ +
+ {# Existing tags (from database) #} + {% for tag in media.tags.all %} + {% include "partials/tags/tag_chip.html" %} + {% endfor %} + {# TMDB tags (pre-filled as new) #} + {% for name in tmdb_tags %} + + {{ name }} + + + + {% endfor %} +
+
+ + +
+ {% if form.tags.errors %} + + {% endif %}
{# Add button #} - + {% lucide "plus" %} @@ -92,87 +94,48 @@

{% translate "My media" %}

{% if filters.type %} {% for type_val, type_label in filters.type_display %} -
- {{ type_label }} - -
+ {% include "partials/filters/filter_badge.html" with label=type_label filter_name="type" filter_value=type_val %} {% endfor %} {% endif %} {% if filters.status %} {% for status_val, status_label in filters.status_display %} -
- {{ status_label }} - -
+ {% include "partials/filters/filter_badge.html" with label=status_label filter_name="status" filter_value=status_val %} {% endfor %} {% endif %} {% if filters.score %} {% for score_val, score_label in filters.score_display %} -
- {{ score_label }} - -
+ {% include "partials/filters/filter_badge.html" with label=score_label filter_name="score" filter_value=score_val %} {% endfor %} {% endif %} {% if filters.review_from or filters.review_to %} -
- 📅 {{ filters.review_from }} - {% if filters.review_from and filters.review_to %}→{% endif %} - {{ filters.review_to }} - -
+ {% with review_label="📅 "|add:filters.review_from|default:""|add:" → "|add:filters.review_to|default:"" %} + {% include "partials/filters/filter_badge.html" with label=review_label filter_name="review" %} + {% endwith %} {% endif %} {% if filters.has_review %} -
- - {% if filters.has_review == 'empty' %} - {% translate "No review" %} - {% else %} - {% translate "Has review" %} - {% endif %} - - -
+ {% if filters.has_review == 'empty' %} + {% translate "No review" as has_review_label %} + {% else %} + {% translate "Has review" as has_review_label %} + {% endif %} + {% include "partials/filters/filter_badge.html" with label=has_review_label filter_name="has_review" %} {% endif %} {% if filters.has_cover %} -
- - {% if filters.has_cover == 'empty' %} - {% translate "No cover" %} - {% else %} - {% translate "Has cover" %} - {% endif %} - - -
+ {% if filters.has_cover == 'empty' %} + {% translate "No cover" as has_cover_label %} + {% else %} + {% translate "Has cover" as has_cover_label %} + {% endif %} + {% include "partials/filters/filter_badge.html" with label=has_cover_label filter_name="has_cover" %} {% endif %} {% if contributor %} -
- {{ contributor.name }} - -
+ {% include "partials/filters/filter_badge.html" with label=contributor.name filter_name="contributor" badge_id="contributor-badge" label_id="contributor-badge-name" %} + {% endif %} + {% if tag %} + {% include "partials/filters/filter_badge.html" with label=tag.name filter_name="tag" badge_id="tag-badge" label_id="tag-badge-name" %} {% endif %} {# Save view button - only shown when filters are active #} - {% if filters.has_any or contributor %} + {% if filters.has_any or contributor or tag %}
{% include "partials/media_items/media_edit_button.html" %}
@@ -72,6 +75,9 @@

{% if media.contributors.all %}
{% include "partials/media_items/media_contributors.html" %}
{% endif %} + {% if media.tags.all %} +
{% include "partials/media_items/media_tags.html" %}
+ {% endif %} {# Show badge on small screens only (hidden from md+) #} {% if media.score %}
{% include "partials/media_items/score/media_score_badge.html" with size="sm" %}
diff --git a/src/templates/partials/media_items/media_tags.html b/src/templates/partials/media_items/media_tags.html new file mode 100644 index 0000000..cdeb469 --- /dev/null +++ b/src/templates/partials/media_items/media_tags.html @@ -0,0 +1,9 @@ +{# Renders the list of tags with links #} +{# Parameters: media #} +{% load media_tags %} +{% if media.tags.all %} + {% for tag in media.tags.all %} + {{ tag.name }} + {% endfor %} +{% endif %} diff --git a/src/templates/partials/navigation/filters_drawer.html b/src/templates/partials/navigation/filters_drawer.html index d67b1d1..04a80cb 100644 --- a/src/templates/partials/navigation/filters_drawer.html +++ b/src/templates/partials/navigation/filters_drawer.html @@ -18,12 +18,15 @@

{% translate "Filters" %}

- {# Hidden fields to preserve view_mode, sort, contributor and search #} + {# Hidden fields to preserve view_mode, sort, contributor, tag and search #} + {% if request.GET.search %}{% endif %} {# Filters content #}
@@ -35,7 +38,10 @@

{% translate "Filters" %}