diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index 310065db..efc00239 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -2,7 +2,7 @@ import logging from django.conf import settings from django.core.signing import TimestampSigner -from django.db.models import Q +from django.db.models import Prefetch, Q from django.urls import reverse from django_filters.rest_framework import DjangoFilterBackend from rest_framework import mixins, status, viewsets @@ -38,6 +38,12 @@ class LibraryViewSet(viewsets.ModelViewSet): if library.auto_scan_enabled and self.request.data.get("trigger_scan"): enqueue_library_scan(library_id=library.id, user_id=self.request.user.id) + def perform_destroy(self, instance): + # Explicitly clean up related media items and files before removing the library + instance.items.all().delete() + instance.files.all().delete() + super().perform_destroy(instance) + @action(detail=True, methods=["post"], url_path="scan") def scan(self, request, pk=None): library = self.get_object() @@ -58,28 +64,89 @@ class LibraryScanViewSet(viewsets.ReadOnlyModelViewSet): class MediaItemViewSet(viewsets.ModelViewSet): - queryset = ( - models.MediaItem.objects.select_related( + serializer_class = serializers.MediaItemSerializer + permission_classes = [Authenticated] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = [ + "library", + "item_type", + "status", + "release_year", + "season_number", + "parent", + ] + search_fields = ["title", "synopsis", "tags"] + ordering_fields = [ + "sort_title", + "release_year", + "first_imported_at", + "updated_at", + "season_number", + "episode_number", + ] + ordering = ["sort_title"] + http_method_names = ["get", "head", "options", "patch", "post"] + pagination_class = None + _stream_signer = TimestampSigner(salt="media-library-stream") + + def get_serializer_class(self): + if self.action == "list": + return serializers.MediaItemListSerializer + return super().get_serializer_class() + + def get_queryset(self): + user = getattr(self.request, "user", None) + if user and user.is_authenticated: + watch_prefetch = Prefetch( + "watch_progress", + queryset=models.WatchProgress.objects.filter(user=user), + to_attr="_user_watch_progress", + ) + episode_watch_prefetch = Prefetch( + "watch_progress", + queryset=models.WatchProgress.objects.filter(user=user), + to_attr="_user_watch_progress", + ) + else: + watch_prefetch = Prefetch( + "watch_progress", + queryset=models.WatchProgress.objects.none(), + to_attr="_user_watch_progress", + ) + episode_watch_prefetch = Prefetch( + "watch_progress", + queryset=models.WatchProgress.objects.none(), + to_attr="_user_watch_progress", + ) + + base_queryset = models.MediaItem.objects.select_related( "library", "parent", "vod_movie", "vod_series", "vod_episode", ) - .prefetch_related("files", "artwork", "watch_progress") - ) - serializer_class = serializers.MediaItemSerializer - permission_classes = [Authenticated] - filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] - filterset_fields = ["library", "item_type", "status", "release_year", "season_number"] - search_fields = ["title", "synopsis", "tags"] - ordering_fields = ["sort_title", "release_year", "first_imported_at", "updated_at"] - ordering = ["sort_title"] - http_method_names = ["get", "head", "options", "patch"] - _stream_signer = TimestampSigner(salt="media-library-stream") - def get_queryset(self): - queryset = super().get_queryset() + if self.action == "list": + children_qs = ( + models.MediaItem.objects.filter(item_type=models.MediaItem.TYPE_EPISODE) + .select_related("parent") + .prefetch_related(episode_watch_prefetch) + .order_by("season_number", "episode_number", "id") + ) + return base_queryset.prefetch_related( + watch_prefetch, + Prefetch( + "children", + queryset=children_qs, + to_attr="_prefetched_children", + ), + ) + + return base_queryset.prefetch_related("files", "artwork", watch_prefetch) + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) search = self.request.query_params.get("search") if search: search = search.strip() @@ -144,7 +211,7 @@ class MediaItemViewSet(viewsets.ModelViewSet): payload = {"file_id": file.id, "user_id": request.user.id} token = self._stream_signer.sign_object(payload) stream_url = request.build_absolute_uri( - reverse("media:stream-file", args=[token]) + reverse("api:media:stream-file", args=[token]) ) ttl = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600) return Response( @@ -181,6 +248,64 @@ class MediaItemViewSet(viewsets.ModelViewSet): progress.update_progress(position_ms=duration or 0, duration_ms=duration or 0) return Response({"status": "ok"}) + @action(detail=True, methods=["post"], url_path="mark-series-watched") + def mark_series_watched(self, request, pk=None): + item = self.get_object() + if item.item_type != models.MediaItem.TYPE_SHOW: + return Response( + {"detail": "Series-level actions are only available for shows."}, + status=status.HTTP_400_BAD_REQUEST, + ) + episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE) + updated = 0 + for episode in episodes: + duration = episode.runtime_ms + if not duration: + primary_file = episode.files.order_by("-duration_ms").first() + duration = primary_file.duration_ms if primary_file else 0 + if not duration: + duration = 1000 + models.WatchProgress.objects.update_or_create( + user=request.user, + media_item=episode, + defaults={ + "position_ms": duration, + "duration_ms": duration, + "completed": True, + }, + ) + updated += 1 + + models.WatchProgress.objects.update_or_create( + user=request.user, + media_item=item, + defaults={ + "position_ms": 0, + "duration_ms": item.runtime_ms or 0, + "completed": True, + }, + ) + + serializer = self.get_serializer(item) + return Response({"updated": updated, "item": serializer.data}) + + @action(detail=True, methods=["post"], url_path="mark-series-unwatched") + def mark_series_unwatched(self, request, pk=None): + item = self.get_object() + if item.item_type != models.MediaItem.TYPE_SHOW: + return Response( + {"detail": "Series-level actions are only available for shows."}, + status=status.HTTP_400_BAD_REQUEST, + ) + episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE) + cleared, _ = models.WatchProgress.objects.filter( + user=request.user, + media_item__in=episodes, + ).delete() + models.WatchProgress.objects.filter(user=request.user, media_item=item).delete() + serializer = self.get_serializer(item) + return Response({"cleared": cleared, "item": serializer.data}) + @action(detail=True, methods=["post"], url_path="clear-progress") def clear_progress(self, request, pk=None): item = self.get_object() diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py index b7abafe9..57987df0 100644 --- a/apps/media_library/metadata.py +++ b/apps/media_library/metadata.py @@ -46,12 +46,25 @@ def build_image_url(path: Optional[str]) -> Optional[str]: return f"{TMDB_IMAGE_BASE}{path}" +def _to_serializable(obj): + """Ensure TMDB data is safe to store in JSON fields.""" + if isinstance(obj, dict): + return {key: _to_serializable(value) for key, value in obj.items()} + if isinstance(obj, (list, tuple, set)): + return [_to_serializable(item) for item in obj] + # Keep primitive JSON types; convert everything else to string + if isinstance(obj, (str, int, float, bool)) or obj is None: + return obj + return str(obj) + + def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: api_key = get_tmdb_api_key() if not _configure_tmdb(api_key): return None - cache_key = f"media-metadata:{media_item.item_type}:{media_item.normalized_title}:{media_item.release_year}" + normalized = (media_item.normalized_title or "").replace(" ", "_") + cache_key = f"media-metadata:{media_item.item_type}:{normalized}:{media_item.release_year}" cached = cache.get(cache_key) if cached: return cached @@ -102,13 +115,33 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: episode_run_time = info.get("episode_run_time") or [] runtime = episode_run_time[0] if episode_run_time else None - cast = [c.get("name") for c in credits.get("cast", [])[:10] if c.get("name")] + cast = [] + for cast_member in credits.get("cast", [])[:12]: + name = cast_member.get("name") + if not name: + continue + cast.append( + { + "name": name, + "character": cast_member.get("character"), + "profile_url": build_image_url(cast_member.get("profile_path")), + } + ) + crew = [] for member in credits.get("crew", [])[:15]: name = member.get("name") job = member.get("job") - if name and job: - crew.append(f"{name} ({job})") + if not name: + continue + crew.append( + { + "name": name, + "job": job, + "department": member.get("department"), + "profile_url": build_image_url(member.get("profile_path")), + } + ) release_year = media_item.release_year release_date = info.get("release_date") or info.get("first_air_date") @@ -118,7 +151,8 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: except (ValueError, TypeError): # noqa: PERF203 release_year = media_item.release_year - metadata = { + metadata = _to_serializable( + { "tmdb_id": str(tmdb_id), "imdb_id": info.get("imdb_id") or media_item.imdb_id, "title": info.get("title") or info.get("name") or media_item.title, @@ -132,6 +166,7 @@ def fetch_tmdb_metadata(media_item: MediaItem) -> Optional[Dict[str, Any]]: "cast": cast, "crew": crew, } + ) cache.set(cache_key, metadata, METADATA_CACHE_TIMEOUT) return metadata diff --git a/apps/media_library/migrations/0002_libraryscan_processed_files.py b/apps/media_library/migrations/0002_libraryscan_processed_files.py new file mode 100644 index 00000000..bfd67383 --- /dev/null +++ b/apps/media_library/migrations/0002_libraryscan_processed_files.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.14 on 2025-09-17 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('media_library', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='libraryscan', + name='processed_files', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/apps/media_library/models.py b/apps/media_library/models.py index f460a03e..a45b0dcb 100644 --- a/apps/media_library/models.py +++ b/apps/media_library/models.py @@ -150,6 +150,7 @@ class LibraryScan(models.Model): started_at = models.DateTimeField(blank=True, null=True) finished_at = models.DateTimeField(blank=True, null=True) total_files = models.PositiveIntegerField(default=0) + processed_files = models.PositiveIntegerField(default=0) new_files = models.PositiveIntegerField(default=0) updated_files = models.PositiveIntegerField(default=0) removed_files = models.PositiveIntegerField(default=0) @@ -169,15 +170,33 @@ class LibraryScan(models.Model): def mark_running(self, task_id: str | None = None): self.status = self.STATUS_RUNNING self.started_at = timezone.now() + self.processed_files = 0 if task_id: self.task_id = task_id - self.save(update_fields=["status", "started_at", "task_id", "updated_at"]) + self.save( + update_fields=[ + "status", + "started_at", + "task_id", + "processed_files", + "updated_at", + ] + ) def mark_completed(self, summary: str = ""): self.status = self.STATUS_COMPLETED self.finished_at = timezone.now() self.summary = summary - self.save(update_fields=["status", "finished_at", "summary", "updated_at"]) + self.processed_files = self.total_files + self.save( + update_fields=[ + "status", + "finished_at", + "summary", + "processed_files", + "updated_at", + ] + ) def mark_failed(self, summary: str = ""): self.status = self.STATUS_FAILED @@ -185,6 +204,38 @@ class LibraryScan(models.Model): self.summary = summary self.save(update_fields=["status", "finished_at", "summary", "updated_at"]) + def record_progress( + self, + *, + processed: int | None = None, + matched: int | None = None, + unmatched: int | None = None, + summary: str | None = None, + ) -> None: + updates: dict[str, object] = {} + + if processed is not None: + self.processed_files = processed + updates["processed_files"] = processed + if matched is not None: + self.matched_items = matched + updates["matched_items"] = matched + if unmatched is not None: + self.unmatched_files = unmatched + updates["unmatched_files"] = unmatched + if summary is not None: + self.summary = summary + updates["summary"] = summary + + if not updates: + return + + now = timezone.now() + updates["updated_at"] = now + self.__class__.objects.filter(pk=self.pk).update(**updates) + self.updated_at = now + + class MediaItem(models.Model): """Represents a logical piece of media (movie, series, season, episode).""" diff --git a/apps/media_library/serializers.py b/apps/media_library/serializers.py index 370c5c77..c06416f2 100644 --- a/apps/media_library/serializers.py +++ b/apps/media_library/serializers.py @@ -1,4 +1,5 @@ from django.db import transaction +from django.db.models import Prefetch from rest_framework import serializers from apps.media_library import models @@ -112,6 +113,7 @@ class LibraryScanSerializer(serializers.ModelSerializer): "started_at", "finished_at", "total_files", + "processed_files", "new_files", "updated_files", "removed_files", @@ -132,6 +134,7 @@ class LibraryScanSerializer(serializers.ModelSerializer): "started_at", "finished_at", "total_files", + "processed_files", "new_files", "updated_files", "removed_files", @@ -206,11 +209,161 @@ class MediaFileSerializer(serializers.ModelSerializer): ] -class MediaItemSerializer(serializers.ModelSerializer): - files = MediaFileSerializer(many=True, read_only=True) - artwork = ArtworkAssetSerializer(many=True, read_only=True) +class MediaItemBaseSerializer(serializers.ModelSerializer): parent_id = serializers.PrimaryKeyRelatedField(source="parent", read_only=True) watch_progress = serializers.SerializerMethodField() + watch_summary = serializers.SerializerMethodField() + + def _get_user(self): + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return None + return user + + def _get_progress(self, obj, user): + prefetched = getattr(obj, "_user_watch_progress", None) + if prefetched is not None: + return prefetched[0] if prefetched else None + return obj.watch_progress.filter(user=user).first() + + def get_watch_progress(self, obj): + user = self._get_user() + if not user: + return None + progress = self._get_progress(obj, user) + if not progress: + return None + duration = progress.duration_ms or obj.runtime_ms + percentage = (progress.position_ms / duration) if duration else 0 + return { + "id": progress.id, + "position_ms": progress.position_ms, + "duration_ms": duration, + "completed": progress.completed, + "percentage": percentage, + "last_watched_at": progress.last_watched_at, + } + + def get_watch_summary(self, obj): + user = self._get_user() + if not user: + return None + + if obj.item_type == models.MediaItem.TYPE_SHOW: + episodes = getattr(obj, "_prefetched_children", None) + if episodes is None: + episodes = list( + models.MediaItem.objects.filter( + parent=obj, item_type=models.MediaItem.TYPE_EPISODE + ) + .prefetch_related( + Prefetch( + "watch_progress", + queryset=models.WatchProgress.objects.filter(user=user), + to_attr="_user_watch_progress", + ) + ) + .order_by("season_number", "episode_number", "id") + ) + total = len([ep for ep in episodes if ep.item_type == models.MediaItem.TYPE_EPISODE]) + completed = 0 + resume_episode = None + resume_progress_time = None + next_episode = None + last_completed_episode = None + last_completed_time = None + + for episode in episodes: + if episode.item_type != models.MediaItem.TYPE_EPISODE: + continue + progress = self._get_progress(episode, user) + if progress and progress.completed: + completed += 1 + if not last_completed_time or progress.last_watched_at > last_completed_time: + last_completed_time = progress.last_watched_at + last_completed_episode = episode + elif progress and progress.position_ms: + if ( + resume_progress_time is None + or progress.last_watched_at > resume_progress_time + ): + resume_progress_time = progress.last_watched_at + resume_episode = episode + else: + if not next_episode: + next_episode = episode + + status = "unwatched" + if total == 0: + status = "unwatched" + elif completed >= total and total > 0: + status = "watched" + next_episode = None + elif resume_episode or completed > 0: + status = "in_progress" + if not resume_episode: + resume_episode = next_episode + + return { + "status": status, + "total_episodes": total, + "completed_episodes": completed, + "resume_episode_id": resume_episode.id if resume_episode else None, + "next_episode_id": next_episode.id if next_episode else None, + "last_completed_episode_id": last_completed_episode.id if last_completed_episode else None, + } + + progress = self._get_progress(obj, user) + if not progress: + return {"status": "unwatched"} + if progress.completed: + return { + "status": "watched", + "position_ms": progress.position_ms, + "duration_ms": progress.duration_ms, + } + return { + "status": "in_progress", + "position_ms": progress.position_ms, + "duration_ms": progress.duration_ms, + "percentage": (progress.position_ms / progress.duration_ms) + if progress.duration_ms + else 0, + } + + +class MediaItemListSerializer(MediaItemBaseSerializer): + class Meta: + model = models.MediaItem + fields = [ + "id", + "library", + "parent_id", + "item_type", + "status", + "title", + "sort_title", + "poster_url", + "backdrop_url", + "runtime_ms", + "release_year", + "season_number", + "episode_number", + "genres", + "tags", + "tagline", + "metadata_last_synced_at", + "metadata_source", + "watch_progress", + "watch_summary", + ] + read_only_fields = fields + + +class MediaItemSerializer(MediaItemBaseSerializer): + files = MediaFileSerializer(many=True, read_only=True) + artwork = ArtworkAssetSerializer(many=True, read_only=True) class Meta: model = models.MediaItem @@ -251,6 +404,7 @@ class MediaItemSerializer(serializers.ModelSerializer): "files", "artwork", "watch_progress", + "watch_summary", ] read_only_fields = [ "id", @@ -277,23 +431,6 @@ class MediaItemSerializer(serializers.ModelSerializer): "metadata": {"required": False, "allow_null": True}, } - def get_watch_progress(self, obj): - request = self.context.get("request") - user = getattr(request, "user", None) - if not user or not user.is_authenticated: - return None - progress = obj.watch_progress.filter(user=user).first() - if not progress: - return None - duration = progress.duration_ms or obj.runtime_ms - return { - "id": progress.id, - "position_ms": progress.position_ms, - "duration_ms": duration, - "completed": progress.completed, - "percentage": (progress.position_ms / duration) if duration else 0, - } - class WatchProgressSerializer(serializers.ModelSerializer): user_display = serializers.CharField(source="user.username", read_only=True) @@ -312,11 +449,14 @@ class WatchProgressSerializer(serializers.ModelSerializer): "completed", "last_watched_at", ] - read_only_fields = ["id", "user_display", "media_title", "last_watched_at"] + read_only_fields = ["id", "user", "user_display", "media_title", "last_watched_at"] + extra_kwargs = {"user": {"required": False}} + validators = [] # DB constraint enforces uniqueness; allow update-or-create writes def create(self, validated_data): + user = self.context["request"].user progress, _ = models.WatchProgress.objects.update_or_create( - user=validated_data["user"], + user=user, media_item=validated_data["media_item"], defaults={ "position_ms": validated_data.get("position_ms", 0), diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py index 07737490..39ca6813 100644 --- a/apps/media_library/tasks.py +++ b/apps/media_library/tasks.py @@ -6,10 +6,12 @@ from celery import shared_task from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.db import transaction +from django.db.models import Q from django.utils import timezone from apps.media_library.metadata import sync_metadata from apps.media_library.models import Library, LibraryScan, MediaFile, MediaItem +from apps.media_library import serializers from apps.media_library.utils import ( LibraryScanner, apply_probe_metadata, @@ -21,6 +23,41 @@ from apps.media_library.utils import ( logger = logging.getLogger(__name__) +def _start_next_scan(library: Library) -> None: + if library.scans.filter(status=LibraryScan.STATUS_RUNNING).exists(): + return + + if library.scans.filter(status=LibraryScan.STATUS_PENDING, task_id__isnull=False).exists(): + return + + pending_scan = ( + library.scans.filter( + status=LibraryScan.STATUS_PENDING, + task_id__isnull=True, + ) + .order_by("created_at") + .first() + ) + + if not pending_scan: + return + + extra = pending_scan.extra or {} + force_full = bool(extra.get("force_full")) + rescan_item_id = extra.get("rescan_item_id") + + async_result = scan_library_task.apply_async( + kwargs={ + "scan_id": str(pending_scan.id), + "library_id": pending_scan.library_id, + "force_full": force_full, + "rescan_item_id": rescan_item_id, + } + ) + pending_scan.task_id = async_result.id + pending_scan.save(update_fields=["task_id", "updated_at"]) + + def enqueue_library_scan( *, library_id: int, @@ -39,16 +76,7 @@ def enqueue_library_scan( }, ) - async_result = scan_library_task.apply_async( - kwargs={ - "scan_id": str(scan.id), - "library_id": library_id, - "force_full": force_full, - "rescan_item_id": rescan_item_id, - } - ) - scan.task_id = async_result.id - scan.save(update_fields=["task_id", "updated_at"]) + _start_next_scan(library) return scan @@ -67,6 +95,27 @@ def _send_scan_event(event: dict) -> None: ) +def _send_media_item_update(media_item: MediaItem, *, status: str = "updated") -> None: + try: + channel_layer = get_channel_layer() + except Exception: # noqa: BLE001 + return + if not channel_layer: + return + + data = serializers.MediaItemListSerializer(media_item, context={"request": None}).data + payload = { + "type": "media_item_update", + "status": status, + "library_id": media_item.library_id, + "media_item": data, + } + async_to_sync(channel_layer.group_send)( + "updates", + {"type": "update", "data": payload}, + ) + + @shared_task(bind=True, name="media_library.scan_library") def scan_library_task( self, @@ -85,6 +134,11 @@ def scan_library_task( scan.mark_running(task_id=self.request.id if self.request else None) library = scan.library logger.info("Starting scan for library %s (id=%s)", library.name, library.id) + processed = 0 + total_files = 0 + matched = 0 + unmatched = 0 + media_item_ids: Set[int] = set() _send_scan_event( { "status": "started", @@ -104,6 +158,7 @@ def scan_library_task( discoveries = _discover_media(scan=scan, scanner=scanner) logger.debug("Discovered %s files for library %s", len(discoveries), library.id) + scan.record_progress(processed=0, matched=0, unmatched=0) _send_scan_event( { "status": "discovered", @@ -112,14 +167,15 @@ def scan_library_task( "files": len(discoveries), "new_files": scan.new_files, "updated_files": scan.updated_files, + "processed": 0, + "processed_files": 0, + "total": len(discoveries), } ) scanner.mark_missing_files() - matched = 0 - unmatched = 0 - media_item_ids: Set[int] = set() + total_files = len(discoveries) for result in discoveries: identify_result = _identify_media_file( @@ -130,14 +186,49 @@ def scan_library_task( matched += identify_result.get("matched", 0) unmatched += identify_result.get("unmatched", 0) media_id = identify_result.get("media_item_id") - if media_id: - media_item_ids.add(media_id) + parent_media_id = identify_result.get("parent_media_item_id") + candidate_ids = { + candidate_id + for candidate_id in (media_id, parent_media_id) + if candidate_id + } + for candidate_id in candidate_ids: + is_new_media = candidate_id not in media_item_ids + media_item_ids.add(candidate_id) + if is_new_media: + try: + media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id) + except MediaItem.DoesNotExist: + continue + else: + _send_media_item_update(media_obj, status="progress") if result.requires_probe: - _probe_media_file(file_id=result.file_id) + probe_media_task.delay(result.file_id) - for media_item_id in media_item_ids: - _sync_metadata(media_item_id) + processed += 1 + scan.record_progress(processed=processed, matched=matched, unmatched=unmatched) + _send_scan_event( + { + "status": "progress", + "scan_id": str(scan.id), + "library_id": library.id, + "processed": processed, + "processed_files": processed, + "total": total_files, + "matched": matched, + "unmatched": unmatched, + } + ) + + if media_item_ids: + metadata_qs = MediaItem.objects.filter(pk__in=media_item_ids).filter( + Q(metadata_last_synced_at__isnull=True) + | Q(poster_url__isnull=True) + | Q(poster_url="") + ) + for item in metadata_qs: + sync_metadata_task.delay(item.id) summary = ( f"Processed {scan.total_files} files; " @@ -158,10 +249,20 @@ def scan_library_task( "new_files": scan.new_files, "updated_files": scan.updated_files, "removed_files": scan.removed_files, + "processed": scan.total_files, + "processed_files": scan.total_files, + "total": scan.total_files, } ) + _start_next_scan(library) except Exception as exc: # noqa: BLE001 logger.exception("Library scan failed for %s", library.name) + scan.record_progress( + processed=processed, + matched=matched, + unmatched=unmatched, + summary=str(exc), + ) scan.mark_failed(summary=str(exc)) _send_scan_event( { @@ -170,8 +271,12 @@ def scan_library_task( "scan_id": str(scan.id), "library_id": library.id, "message": str(exc), + "processed": processed, + "processed_files": processed, + "total": total_files, } ) + _start_next_scan(library) raise @@ -203,6 +308,12 @@ def _identify_media_file( return {"matched": 0, "unmatched": 0} classification = classify_media_file(file_record.file_name) + if library.library_type == Library.LIBRARY_TYPE_MOVIES: + classification.detected_type = MediaItem.TYPE_MOVIE + elif library.library_type == Library.LIBRARY_TYPE_SHOWS: + if classification.detected_type == MediaItem.TYPE_MOVIE: + classification.detected_type = MediaItem.TYPE_SHOW + # mixed/other retain detected type target_item = None if target_item_id: target_item = MediaItem.objects.filter(pk=target_item_id, library=library).first() @@ -223,6 +334,17 @@ def _identify_media_file( if media_item.status != MediaItem.STATUS_MATCHED: media_item.status = MediaItem.STATUS_MATCHED media_item.save(update_fields=["status", "updated_at"]) + if ( + classification.detected_type == MediaItem.TYPE_EPISODE + and media_item.parent_id + ): + parent = MediaItem.objects.filter(pk=media_item.parent_id).first() + if parent: + if parent.status != MediaItem.STATUS_MATCHED: + parent.status = MediaItem.STATUS_MATCHED + parent.save(update_fields=["status", "updated_at"]) + if not parent.metadata_last_synced_at or not parent.poster_url: + sync_metadata_task.delay(parent.id) matched = 1 else: unmatched = 1 @@ -234,6 +356,7 @@ def _identify_media_file( return { "file_id": file_id, "media_item_id": media_item.id if media_item else None, + "parent_media_item_id": media_item.parent_id if media_item else None, "matched": matched, "unmatched": unmatched, } @@ -267,7 +390,9 @@ def _sync_metadata(media_item_id: int) -> None: media_item = MediaItem.objects.get(pk=media_item_id) except MediaItem.DoesNotExist: return - sync_metadata(media_item) + result = sync_metadata(media_item) + if result: + _send_media_item_update(result, status="metadata") @shared_task(name="media_library.sync_metadata") diff --git a/apps/media_library/utils.py b/apps/media_library/utils.py index 4c30ac44..3733834b 100644 --- a/apps/media_library/utils.py +++ b/apps/media_library/utils.py @@ -4,13 +4,27 @@ import os import subprocess from collections import defaultdict from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone as dt_timezone +from importlib import resources as importlib_resources from pathlib import Path from typing import Dict, Iterable, List, Optional from django.db import transaction from django.utils import timezone from guessit import guessit + +try: + from guessit.rules.properties import website as guessit_website +except Exception: # noqa: BLE001 + guessit_website = None +else: + # Python 3.13's importlib.resources.files no longer returns a context manager; + # wrap it so guessit can still use `with files(...)`. + def _compatible_files(package: str): # type: ignore[override] + return importlib_resources.as_file(importlib_resources.files(package)) + + if guessit_website: + guessit_website.files = _compatible_files from pymediainfo import MediaInfo from apps.media_library.models import ( @@ -27,6 +41,17 @@ from apps.media_library.models import ( logger = logging.getLogger(__name__) +def _json_safe(value): + """Ensure value can be serialized via json.dumps.""" + if isinstance(value, dict): + return {key: _json_safe(val) for key, val in value.items()} + if isinstance(value, (list, tuple, set)): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + @dataclass class DiscoveredFile: file_id: int @@ -145,6 +170,7 @@ class LibraryScanner: self.scan.log = "\n".join(self.log_messages) self.scan.finished_at = timezone.now() self.scan.status = LibraryScan.STATUS_COMPLETED + self.scan.processed_files = self.scan.total_files self.scan.save( update_fields=[ "matched_items", @@ -153,6 +179,7 @@ class LibraryScanner: "log", "finished_at", "status", + "processed_files", "updated_at", ] ) @@ -166,7 +193,7 @@ class LibraryScanner: ) -> Optional[tuple[MediaFile, bool]]: relative_path = os.path.relpath(file_path, location.path) stat = file_path.stat() - last_modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + last_modified = datetime.fromtimestamp(stat.st_mtime, tz=dt_timezone.utc) file_record, created = MediaFile.objects.select_for_update().get_or_create( library=self.library, @@ -235,6 +262,8 @@ def classify_media_file(file_name: str) -> ClassificationResult: data={"error": str(exc)}, ) + data = _json_safe(data) + guess_type = data.get("type") detected_type = MediaItem.TYPE_OTHER @@ -278,16 +307,22 @@ def resolve_media_item( normalized = normalize_title(title) if classification.detected_type == MediaItem.TYPE_MOVIE: - match = ( - MediaItem.objects.filter( - library=library, - item_type=MediaItem.TYPE_MOVIE, - normalized_title=normalized, - release_year=classification.year, - ).first() + queryset = MediaItem.objects.filter( + library=library, + item_type=MediaItem.TYPE_MOVIE, + normalized_title=normalized, ) + match = None + if classification.year: + match = queryset.filter(release_year=classification.year).first() + if not match: + match = queryset.first() if match: + if classification.year and match.release_year != classification.year: + match.release_year = classification.year + match.save(update_fields=["release_year", "updated_at"]) return match + metadata_payload = _json_safe(classification.data or {}) return MediaItem.objects.create( library=library, item_type=MediaItem.TYPE_MOVIE, @@ -296,17 +331,16 @@ def resolve_media_item( sort_title=title, normalized_title=normalized, release_year=classification.year, - metadata=classification.data or {}, + metadata=metadata_payload, ) if classification.detected_type == MediaItem.TYPE_SHOW: - match = ( - MediaItem.objects.filter( - library=library, - item_type=MediaItem.TYPE_SHOW, - normalized_title=normalized, - ).first() - ) + metadata_payload = _json_safe(classification.data or {}) + match = MediaItem.objects.filter( + library=library, + item_type=MediaItem.TYPE_SHOW, + normalized_title=normalized, + ).first() if match: return match return MediaItem.objects.create( @@ -316,7 +350,7 @@ def resolve_media_item( title=title, sort_title=title, normalized_title=normalized, - metadata=classification.data or {}, + metadata=metadata_payload, ) if classification.detected_type == MediaItem.TYPE_EPISODE: @@ -333,6 +367,7 @@ def resolve_media_item( ).first() ) if not series_item: + series_metadata = _json_safe(classification.data or {}) series_item = MediaItem.objects.create( library=library, item_type=MediaItem.TYPE_SHOW, @@ -340,7 +375,7 @@ def resolve_media_item( title=series_title, sort_title=series_title, normalized_title=series_normalized, - metadata=classification.data or {}, + metadata=series_metadata, ) episode_item = ( @@ -367,6 +402,7 @@ def resolve_media_item( ) ) + metadata_payload = _json_safe(classification.data or {}) return MediaItem.objects.create( library=library, parent=series_item, @@ -378,9 +414,17 @@ def resolve_media_item( release_year=classification.year, season_number=classification.season, episode_number=classification.episode, - metadata=classification.data or {}, + metadata=metadata_payload, ) + metadata_payload = _json_safe(classification.data or {}) + match = MediaItem.objects.filter( + library=library, + item_type=MediaItem.TYPE_OTHER, + normalized_title=normalized, + ).first() + if match: + return match return MediaItem.objects.create( library=library, item_type=MediaItem.TYPE_OTHER, @@ -388,7 +432,7 @@ def resolve_media_item( title=title, sort_title=title, normalized_title=normalized, - metadata=classification.data or {}, + metadata=metadata_payload, ) diff --git a/apps/media_library/views.py b/apps/media_library/views.py index 4d6223c0..5c7ac212 100644 --- a/apps/media_library/views.py +++ b/apps/media_library/views.py @@ -1,9 +1,16 @@ import mimetypes import os +import re from django.conf import settings from django.core.signing import BadSignature, SignatureExpired, TimestampSigner -from django.http import FileResponse, Http404, HttpResponseForbidden +from django.http import ( + FileResponse, + Http404, + HttpResponse, + HttpResponseForbidden, + StreamingHttpResponse, +) from django.views.decorators.http import require_GET from apps.media_library.models import MediaFile @@ -11,6 +18,23 @@ from apps.media_library.models import MediaFile STREAM_SIGNER = TimestampSigner(salt="media-library-stream") TOKEN_TTL = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600) +mimetypes.add_type("video/x-matroska", ".mkv", strict=False) + + +def _iter_file(file_obj, offset=0, length=None, chunk_size=8192): + file_obj.seek(offset) + remaining = length + while True: + if remaining is not None and remaining <= 0: + break + read_size = chunk_size if remaining is None else min(chunk_size, remaining) + data = file_obj.read(read_size) + if not data: + break + if remaining is not None: + remaining -= len(data) + yield data + @require_GET def stream_media_file(request, token: str): @@ -38,13 +62,43 @@ def stream_media_file(request, token: str): mime_type, _ = mimetypes.guess_type(path) mime_type = mime_type or "application/octet-stream" + file_size = os.path.getsize(path) + + range_header = request.headers.get("Range") + if range_header: + range_match = re.match(r"bytes=(\d+)-(\d*)", range_header) + if range_match: + start = int(range_match.group(1)) + end_raw = range_match.group(2) + end = int(end_raw) if end_raw else file_size - 1 + if start >= file_size: + response = HttpResponse(status=416) + response["Content-Range"] = f"bytes */{file_size}" + return response + end = min(end, file_size - 1) + length = end - start + 1 + + file_handle = open(path, "rb") + + def closing_iterator(): + try: + yield from _iter_file(file_handle, offset=start, length=length) + finally: + file_handle.close() + + response = StreamingHttpResponse( + closing_iterator(), status=206, content_type=mime_type + ) + response["Content-Length"] = str(length) + response["Content-Range"] = f"bytes {start}-{end}/{file_size}" + response["Accept-Ranges"] = "bytes" + response["Content-Disposition"] = ( + f"inline; filename=\"{os.path.basename(path)}\"" + ) + return response response = FileResponse(open(path, "rb"), content_type=mime_type) - try: - size = os.path.getsize(path) - response["Content-Length"] = str(size) - except OSError: - pass - + response["Accept-Ranges"] = "bytes" + response["Content-Length"] = str(file_size) response["Content-Disposition"] = f"inline; filename=\"{os.path.basename(path)}\"" return response diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0c6556ee..2f674d42 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -148,7 +148,8 @@ const App = () => { } /> } /> } /> - } /> + } /> + } /> > ) : ( } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index b82d01c0..ef56171b 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -13,6 +13,7 @@ import useLogosStore from './store/logos'; import usePlaylistsStore from './store/playlists'; import useEPGsStore from './store/epgs'; import useLibraryStore from './store/library'; +import useMediaLibraryStore from './store/mediaLibrary'; import { Box, Button, Stack, Alert, Group } from '@mantine/core'; import API from './api'; import useSettingsStore from './store/settings'; @@ -228,8 +229,20 @@ export const WebsocketProvider = ({ children }) => { } case 'media_scan': { applyMediaScanUpdate(parsedEvent.data); + if (parsedEvent.data.media_item) { + useMediaLibraryStore.getState().upsertItems([ + parsedEvent.data.media_item, + ]); + } if (parsedEvent.data.status === 'completed') { + const { library_id: libraryId } = parsedEvent.data; + if ( + libraryId && + useMediaLibraryStore.getState().selectedLibraryId === libraryId + ) { + useMediaLibraryStore.getState().fetchItems(libraryId); + } notifications.show({ title: 'Library scan complete', message: parsedEvent.data.summary || 'Library scan finished successfully.', @@ -244,6 +257,14 @@ export const WebsocketProvider = ({ children }) => { } break; } + case 'media_item_update': { + if (parsedEvent.data.media_item) { + useMediaLibraryStore.getState().upsertItems([ + parsedEvent.data.media_item, + ]); + } + break; + } case 'epg_file': fetchEPGs(); notifications.show({ diff --git a/frontend/src/api.js b/frontend/src/api.js index 33db1fb9..4fb2e19d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2312,6 +2312,21 @@ export default class API { } } + static async getMediaItemEpisodes(parentId) { + try { + const params = new URLSearchParams(); + params.append('parent', parentId); + params.append('ordering', 'season_number,episode_number'); + const response = await request( + `${host}/api/media/items/?${params.toString()}` + ); + return response.results || response; + } catch (e) { + errorNotification('Failed to load episodes', e); + throw e; + } + } + static async refreshMediaItemMetadata(id) { try { const response = await request( @@ -2327,6 +2342,48 @@ export default class API { } } + static async markSeriesWatched(id) { + try { + const response = await request( + `${host}/api/media/items/${id}/mark-series-watched/`, + { + method: 'POST', + } + ); + return response; + } catch (e) { + errorNotification('Failed to mark series watched', e); + throw e; + } + } + + static async markSeriesUnwatched(id) { + try { + const response = await request( + `${host}/api/media/items/${id}/mark-series-unwatched/`, + { + method: 'POST', + } + ); + return response; + } catch (e) { + errorNotification('Failed to mark series unwatched', e); + throw e; + } + } + + static async deleteMediaItem(id) { + try { + await request(`${host}/api/media/items/${id}/`, { + method: 'DELETE', + }); + return true; + } catch (e) { + errorNotification('Failed to delete media item', e); + throw e; + } + } + static async markMediaItemWatched(id) { try { const response = await request( diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 3c8cfe66..3b6ebe70 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -3,7 +3,8 @@ import React, { useEffect, useRef, useState } from 'react'; import Draggable from 'react-draggable'; import useVideoStore from '../store/useVideoStore'; import mpegts from 'mpegts.js'; -import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core'; +import { CloseButton, Flex, Loader, Text, Box, Button, Progress, Group } from '@mantine/core'; +import { Play } from 'lucide-react'; import API from '../api'; import useAuthStore from '../store/auth'; @@ -21,6 +22,11 @@ export default function FloatingVideo() { const [showOverlay, setShowOverlay] = useState(true); const overlayTimeoutRef = useRef(null); const lastProgressSentRef = useRef(0); + const [nextAutoplay, setNextAutoplay] = useState(null); + const [autoplayCountdown, setAutoplayCountdown] = useState(null); + const autoPlayTimerRef = useRef(null); + const countdownIntervalRef = useRef(null); + const AUTOPLAY_SECONDS = 10; const authUser = useAuthStore((s) => s.user); const sendLibraryProgress = (positionSeconds, durationSeconds, completed = false) => { @@ -38,6 +44,19 @@ export default function FloatingVideo() { }); }; + const clearAutoPlayTimers = () => { + if (autoPlayTimerRef.current) { + clearTimeout(autoPlayTimerRef.current); + autoPlayTimerRef.current = null; + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + setNextAutoplay(null); + setAutoplayCountdown(null); + }; + // Safely destroy the mpegts player to prevent errors const safeDestroyPlayer = () => { try { @@ -81,6 +100,8 @@ export default function FloatingVideo() { clearTimeout(overlayTimeoutRef.current); overlayTimeoutRef.current = null; } + + clearAutoPlayTimers(); }; // Start overlay auto-hide timer @@ -93,6 +114,104 @@ export default function FloatingVideo() { }, 4000); // Hide after 4 seconds }; + const playEpisodeAtIndex = async (index) => { + const sequence = metadata?.playbackSequence; + const episodeIds = sequence?.episodeIds || []; + const nextEpisodeId = episodeIds[index]; + if (!nextEpisodeId) { + clearAutoPlayTimers(); + return; + } + + clearAutoPlayTimers(); + try { + const episodeDetail = await API.getMediaItem(nextEpisodeId); + const fileId = episodeDetail.files?.[0]?.id; + if (!fileId) { + setLoadError('Next episode is missing media files.'); + return; + } + const streamInfo = await API.streamMediaItem(episodeDetail.id, { + fileId, + }); + const playbackUrl = streamInfo?.url || streamInfo?.stream_url; + if (!playbackUrl) { + setLoadError('Streaming endpoint did not return a playable URL.'); + return; + } + + const summary = episodeDetail.watch_summary; + const resumePositionMs = + summary?.status === 'in_progress' + ? summary.position_ms || 0 + : episodeDetail.watch_progress?.position_ms || 0; + const durationMs = + summary?.duration_ms || episodeDetail.watch_progress?.duration_ms || episodeDetail.runtime_ms; + + const playbackSequence = { + episodeIds, + currentIndex: index, + }; + + useVideoStore.getState().showVideo(playbackUrl, 'library', { + mediaItemId: episodeDetail.id, + mediaTitle: episodeDetail.title, + showId: metadata?.showId, + showTitle: metadata?.showTitle, + name: episodeDetail.title, + year: episodeDetail.release_year, + logo: + episodeDetail.poster_url + ? { url: episodeDetail.poster_url } + : metadata?.logo || (metadata?.showPoster ? { url: metadata.showPoster } : undefined), + progressId: episodeDetail.watch_progress?.id, + resumePositionMs, + durationMs, + fileId, + playbackSequence, + }); + } catch (error) { + console.error('Failed to auto-play next episode', error); + setLoadError('Unable to start the next episode automatically.'); + } + }; + + const startAutoPlayCountdown = (nextIndex) => { + const sequence = metadata?.playbackSequence; + if (!sequence?.episodeIds || nextIndex >= sequence.episodeIds.length) { + return; + } + clearAutoPlayTimers(); + setNextAutoplay({ index: nextIndex, episodeId: sequence.episodeIds[nextIndex] }); + setAutoplayCountdown(AUTOPLAY_SECONDS); + countdownIntervalRef.current = setInterval(() => { + setAutoplayCountdown((prev) => { + if (prev === null) return null; + if (prev <= 1) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + autoPlayTimerRef.current = setTimeout(() => { + playEpisodeAtIndex(nextIndex); + }, AUTOPLAY_SECONDS * 1000); + }; + + const cancelAutoPlay = () => { + clearAutoPlayTimers(); + }; + + const playNextEpisodeNow = () => { + if (nextAutoplay) { + playEpisodeAtIndex(nextAutoplay.index); + } + }; + // Initialize VOD player (native HTML5 with enhanced controls) const initializeVODPlayer = () => { if (!videoRef.current || !streamUrl) return; @@ -189,6 +308,13 @@ export default function FloatingVideo() { if (contentType !== 'library') return; if (!video.duration || Number.isNaN(video.duration)) return; sendLibraryProgress(video.duration, video.duration, true); + const sequence = metadata?.playbackSequence; + if (sequence?.episodeIds?.length) { + const nextIndex = (sequence.currentIndex ?? -1) + 1; + if (nextIndex < sequence.episodeIds.length) { + startAutoPlayCountdown(nextIndex); + } + } }; // Add event listeners @@ -358,6 +484,10 @@ export default function FloatingVideo() { }; }, [isVisible, streamUrl, contentType]); + useEffect(() => { + clearAutoPlayTimers(); + }, [metadata?.mediaItemId]); + // Modified hideVideo handler to clean up player first const handleClose = (e) => { if (e) { @@ -507,6 +637,65 @@ export default function FloatingVideo() { )} + + {nextAutoplay && autoplayCountdown !== null && ( + e.stopPropagation()} + > + + Next episode starts in {autoplayCountdown}s + + + + { + e.stopPropagation(); + cancelAutoPlay(); + }} + > + Cancel + + } + onClick={(e) => { + e.stopPropagation(); + playNextEpisodeNow(); + }} + > + Play now + + + + )} {/* Error message below video - doesn't block controls */} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index d0389089..1196a005 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -2,10 +2,11 @@ import React, { useRef, useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; import { + ChevronDown, + ChevronRight, ListOrdered, Play, Database, - SlidersHorizontal, LayoutGrid, Settings as LucideSettings, Copy, @@ -107,8 +108,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }, { label: 'Media Library', - path: '/library', icon: , + children: [ + { label: 'Movies', path: '/library/movies' }, + { label: 'TV Shows', path: '/library/shows' }, + ], }, { label: 'M3U & EPG Manager', @@ -145,8 +149,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { { label: 'TV Guide', icon: , path: '/guide' }, { label: 'Media Library', - path: '/library', icon: , + children: [ + { label: 'Movies', path: '/library/movies' }, + { label: 'TV Shows', path: '/library/shows' }, + ], }, { label: 'Settings', @@ -155,6 +162,16 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }, ]; + const [libraryExpanded, setLibraryExpanded] = useState( + location.pathname.startsWith('/library') + ); + + useEffect(() => { + if (location.pathname.startsWith('/library')) { + setLibraryExpanded(true); + } + }, [location.pathname]); + // Fetch environment settings including version on component mount useEffect(() => { if (!isAuthenticated) { @@ -255,7 +272,64 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {/* Navigation Links */} {navItems.map((item) => { - const isActive = location.pathname === item.path; + const isActive = item.path + ? location.pathname.startsWith(item.path) + : false; + + if (item.children) { + const childActive = item.children.some((child) => + location.pathname.startsWith(child.path) + ); + const expanded = libraryExpanded || childActive; + return ( + + setLibraryExpanded((prev) => !prev)} + > + {item.icon} + {!collapsed && ( + + + {item.label} + + {expanded ? : } + + )} + + {expanded && !collapsed && ( + + {item.children.map((child) => { + const active = location.pathname.startsWith(child.path); + return ( + + {child.label} + + ); + })} + + )} + + ); + } return ( { + setIsIdle(false); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => setIsIdle(true), IDLE_FADE_MS); + }; + + useEffect(() => () => idleTimerRef.current && clearTimeout(idleTimerRef.current), []); + + const handleMouseMove = (e) => { + if (!sidebarRef.current) return; + const rect = sidebarRef.current.getBoundingClientRect(); + setMouseY(e.clientY - rect.top); + poke(); + }; + + const handleMouseLeave = () => { + setIsHot(false); + setIsIdle(false); + setMouseY(null); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + + // Precompute the center Y for each letter button (approx.) + const centers = useMemo(() => { + // each item height ≈ font + vertical padding (~6px top/bottom) + gap + const itemH = BASE_FONT_PX + 12 + LETTER_GAP; // tweak if needed to match your Text styles + return letters.map((_, i) => i * itemH + itemH / 2); + }, []); + + // Visible when hovering the hot zone or the sidebar, unless idling. + const visible = isHot && !isIdle; + + return ( + <> + {/* Invisible hot zone on the far right edge */} + setIsHot(true)} + onMouseLeave={handleMouseLeave} + style={{ + position: 'fixed', + top, + right: 0, + width: HOTZONE_WIDTH, + height: '70vh', + zIndex: 2, + }} + /> + + {/* Sidebar itself */} + setIsHot(true)} + onMouseLeave={handleMouseLeave} + onMouseMove={handleMouseMove} + style={{ + position: 'fixed', + top, + right, + zIndex: 3, + background: 'rgba(12, 12, 16, 0.75)', + borderRadius: 12, + padding: '10px 6px', + backdropFilter: 'blur(4px)', + // reveal/fade logic + opacity: visible ? 1 : 0, + pointerEvents: visible ? 'auto' : 'none', // click-through when hidden + transform: visible ? 'translateX(0)' : 'translateX(8px)', // subtle slide + transition: 'opacity 160ms ease, transform 160ms ease', + }} + > + {letters.map((letter, idx) => { + const isEnabled = available.has(letter); + // proximity scale + let scale = 1; + if (mouseY != null) { + const d = Math.abs(mouseY - centers[idx]); + const boost = Math.exp(-(d * d) / (2 * SIGMA_PX * SIGMA_PX)); // 0..1 + scale = 1 + MAX_SCALE_BOOST * boost; + } + + return ( + isEnabled && onSelect?.(letter)} + style={{ + opacity: isEnabled ? 1 : 0.3, + cursor: isEnabled ? 'pointer' : 'default', + transform: `scale(${scale})`, + transition: 'transform 80ms linear', + willChange: 'transform', + }} + > + + {letter} + + + ); + })} + + > + ); +} diff --git a/frontend/src/components/library/LibraryFormModal.jsx b/frontend/src/components/library/LibraryFormModal.jsx index 2a43dcd0..c1e95898 100644 --- a/frontend/src/components/library/LibraryFormModal.jsx +++ b/frontend/src/components/library/LibraryFormModal.jsx @@ -129,6 +129,7 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) => { bullet={{scan.status}} > - Summary: {scan.summary || 'No summary yet'} + {(() => { + const total = scan.total_files ?? scan.files ?? 0; + if (!total) return null; + const processedRaw = + scan.status === 'completed' + ? scan.total_files ?? scan.processed_files ?? scan.processed ?? 0 + : scan.processed_files ?? scan.processed ?? 0; + const processed = Math.min(processedRaw, total); + const percent = total + ? Math.min(100, Math.round((processed / total) * 100)) + : 0; + return ( + + + + {processed} / {total} processed + + + {percent}% + + + + + ); + })()} + + Summary: {scan.summary || 'No summary yet'} + Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} diff --git a/frontend/src/components/library/MediaCard.jsx b/frontend/src/components/library/MediaCard.jsx new file mode 100644 index 00000000..a7e70538 --- /dev/null +++ b/frontend/src/components/library/MediaCard.jsx @@ -0,0 +1,167 @@ +import React, { memo } from 'react'; +import { + Badge, + Box, + Card, + Group, + Image, + RingProgress, + Stack, + Text, +} from '@mantine/core'; +import { Film, Library as LibraryIcon, Tv2 } from 'lucide-react'; + +const typeIcon = { + movie: , + episode: , + show: , +}; + +const formatRuntime = (runtimeMs) => { + if (!runtimeMs) return null; + const mins = Math.round(runtimeMs / 60000); + if (mins < 60) return `${mins} min`; + const hours = Math.floor(mins / 60); + const minutes = mins % 60; + return `${hours}h ${minutes}m`; +}; + +const MediaCard = ({ + item, + onClick, + onContextMenu, + size = 'md', + showTypeBadge = true, +}) => { + const handleContextMenu = (event) => { + if (onContextMenu) { + event.preventDefault(); + onContextMenu(event, item); + } + }; + + const height = size === 'sm' ? 220 : size === 'lg' ? 320 : 260; + const progress = item.watch_progress; + const watchSummary = item.watch_summary; + const status = watchSummary?.status; + + return ( + onClick?.(item)} + onContextMenu={handleContextMenu} + > + + + {item.poster_url ? ( + + + + ) : ( + + {typeIcon[item.item_type] || } + + )} + {progress && progress.percentage ? ( + + {Math.round(progress.percentage * 100)}% + + } + /> + ) : null} + + + + + {item.title} + + {item.release_year && ( + + {item.release_year} + + )} + + + {showTypeBadge && ( + + {item.item_type} + + )} + {status === 'watched' && ( + + Watched + + )} + {status === 'in_progress' && ( + + In progress + + )} + {item.item_type === 'show' && watchSummary?.total_episodes ? ( + + {watchSummary.completed_episodes || 0}/ + {watchSummary.total_episodes} episodes + + ) : null} + + {Array.isArray(item.genres) && item.genres.length > 0 ? ( + + {item.genres.slice(0, 3).map((genre) => ( + + {genre} + + ))} + + ) : null} + {formatRuntime(item.runtime_ms) && ( + + {formatRuntime(item.runtime_ms)} + + )} + + + + ); +}; + +export default memo(MediaCard); diff --git a/frontend/src/components/library/MediaCarousel.jsx b/frontend/src/components/library/MediaCarousel.jsx new file mode 100644 index 00000000..780bc336 --- /dev/null +++ b/frontend/src/components/library/MediaCarousel.jsx @@ -0,0 +1,109 @@ +import React, { useMemo, useRef } from 'react'; +import { ActionIcon, Box, Group, ScrollArea, Stack, Text, rem } from '@mantine/core'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import MediaCard from './MediaCard'; + +const CARD_WIDTH = { + sm: 160, // wider + md: 200, // wider + lg: 240, // wider +}; + +const SCROLL_STEP = 4; + +const MediaCarousel = ({ + title, + items, + onSelect, + onContextMenu, + cardSize = 'sm', + emptyMessage = null, +}) => { + const viewportRef = useRef(null); + const cardWidth = useMemo(() => CARD_WIDTH[cardSize], [cardSize]); + const snapGap = 16; + const bottomPad = 14; // extra space so card shadows/badges aren't clipped + + if (!items || items.length === 0) { + if (!emptyMessage) return null; + return ( + + {title} + {emptyMessage} + + ); + } + + const scrollByCards = (dir) => { + const vp = viewportRef.current; + if (!vp) return; + vp.scrollBy({ + left: dir * (cardWidth + snapGap) * SCROLL_STEP, + behavior: 'smooth', + }); + }; + + return ( + + + {title} + + scrollByCards(-1)}> + + + scrollByCards(1)}> + + + + + + + + {items.map((item) => ( + + + + ))} + + + + ); +}; + +export default MediaCarousel; diff --git a/frontend/src/components/library/MediaDetailModal.jsx b/frontend/src/components/library/MediaDetailModal.jsx index 8c6382bc..4a7b1193 100644 --- a/frontend/src/components/library/MediaDetailModal.jsx +++ b/frontend/src/components/library/MediaDetailModal.jsx @@ -1,17 +1,17 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActionIcon, + Avatar, Badge, + Box, Button, Divider, Group, Image, - List, Loader, Modal, ScrollArea, Stack, - Table, Text, Title, } from '@mantine/core'; @@ -20,11 +20,11 @@ import { CheckCircle2, Clock, DownloadCloud, - FolderOpen, Info, PlayCircle, RefreshCcw, Undo2, + Trash2, } from 'lucide-react'; import API from '../../api'; @@ -43,39 +43,123 @@ const runtimeLabel = (runtimeMs) => { }; const MediaDetailModal = ({ opened, onClose }) => { - const { - activeItem, - activeItemLoading, - activeProgress, - resumePrompt, - requestResume, - clearResumePrompt, - setActiveProgress, - } = useMediaLibraryStore((state) => ({ - activeItem: state.activeItem, - activeItemLoading: state.activeItemLoading, - activeProgress: state.activeProgress, - resumePrompt: state.resumePrompt, - requestResume: state.requestResume, - clearResumePrompt: state.clearResumePrompt, - setActiveProgress: state.setActiveProgress, - })); + const activeItem = useMediaLibraryStore((state) => state.activeItem); + const activeItemLoading = useMediaLibraryStore((state) => state.activeItemLoading); + const activeProgress = useMediaLibraryStore((state) => state.activeProgress); + const resumePrompt = useMediaLibraryStore((state) => state.resumePrompt); + const requestResume = useMediaLibraryStore((state) => state.requestResume); + const clearResumePrompt = useMediaLibraryStore((state) => state.clearResumePrompt); + const setActiveProgress = useMediaLibraryStore((state) => state.setActiveProgress); const showVideo = useVideoStore((state) => state.showVideo); - const [selectedFileId, setSelectedFileId] = useState(null); const [startingPlayback, setStartingPlayback] = useState(false); const [resumeModalOpen, setResumeModalOpen] = useState(false); const [resumeMode, setResumeMode] = useState('start'); - const [markingWatched, setMarkingWatched] = useState(false); - const [clearingProgress, setClearingProgress] = useState(false); + const [episodes, setEpisodes] = useState([]); + const [episodesLoading, setEpisodesLoading] = useState(false); + const [episodePlayLoadingId, setEpisodePlayLoadingId] = useState(null); + const [episodeActionLoading, setEpisodeActionLoading] = useState({}); - useEffect(() => { - if (activeItem?.files?.length) { - setSelectedFileId(activeItem.files[0].id); - } else { - setSelectedFileId(null); + const setEpisodeLoading = useCallback((episodeId, action) => { + setEpisodeActionLoading((prev) => ({ ...prev, [episodeId]: action })); + }, []); + + const clearEpisodeLoading = useCallback((episodeId) => { + setEpisodeActionLoading((prev) => { + const updated = { ...prev }; + delete updated[episodeId]; + return updated; + }); + }, []); + + const loadEpisodes = useCallback(async () => { + if (!activeItem || activeItem.item_type !== 'show') { + setEpisodes([]); + setEpisodesLoading(false); + return; + } + setEpisodesLoading(true); + try { + const results = await API.getMediaItemEpisodes(activeItem.id); + setEpisodes(Array.isArray(results) ? results : []); + } catch (error) { + console.error('Failed to load episodes for show', error); + notifications.show({ + title: 'Episodes unavailable', + message: 'Unable to load episodes for this series right now.', + color: 'red', + }); + setEpisodes([]); + } finally { + setEpisodesLoading(false); } }, [activeItem]); + const refreshActiveItem = useCallback(async () => { + if (!activeItem) return null; + return useMediaLibraryStore.getState().openItem(activeItem.id); + }, [activeItem]); + + const orderedEpisodes = useMemo(() => { + if (!episodes || episodes.length === 0) { + return []; + } + return [...episodes].sort((a, b) => { + const seasonA = a.season_number ?? 0; + const seasonB = b.season_number ?? 0; + if (seasonA !== seasonB) { + return seasonA - seasonB; + } + const episodeA = a.episode_number ?? 0; + const episodeB = b.episode_number ?? 0; + if (episodeA !== episodeB) { + return episodeA - episodeB; + } + return (a.sort_title || '').localeCompare(b.sort_title || ''); + }); + }, [episodes]); + + const showWatchSummary = activeItem?.watch_summary || null; + + const playbackPlan = useMemo(() => { + if (!activeItem || activeItem.item_type !== 'show') { + return null; + } + const sorted = orderedEpisodes; + if (!sorted || sorted.length === 0) { + return { sorted: [], resumeEpisode: null, nextEpisode: null }; + } + + const resumeId = showWatchSummary?.resume_episode_id; + const nextId = showWatchSummary?.next_episode_id; + let resumeEpisode = sorted.find((ep) => ep.id === resumeId) || null; + let nextEpisode = sorted.find((ep) => ep.id === nextId) || null; + + if (!resumeEpisode) { + resumeEpisode = sorted.find( + (ep) => ep.watch_summary?.status === 'in_progress' + ) || null; + } + + if (!resumeEpisode) { + resumeEpisode = sorted.find( + (ep) => ep.watch_summary?.status !== 'watched' + ) || sorted[0]; + } + + if (!nextEpisode && resumeEpisode) { + const currentIndex = sorted.findIndex((ep) => ep.id === resumeEpisode.id); + if (currentIndex >= 0 && currentIndex + 1 < sorted.length) { + nextEpisode = sorted[currentIndex + 1]; + } + } + + return { + sorted, + resumeEpisode, + nextEpisode, + }; + }, [activeItem, orderedEpisodes, showWatchSummary]); + const canResume = useMemo(() => { if (!activeProgress || !activeProgress.position_ms || !activeProgress.duration_ms) { return false; @@ -84,18 +168,101 @@ const MediaDetailModal = ({ opened, onClose }) => { return remaining > activeProgress.duration_ms * 0.04; }, [activeProgress]); + const activeProgressId = activeProgress?.id; + useEffect(() => { - if (canResume && activeProgress?.id) { - requestResume(activeProgress.id); + if (canResume && activeProgressId && !resumePrompt) { + requestResume(activeProgressId); } - }, [canResume, activeProgress, requestResume]); + }, [canResume, activeProgressId, resumePrompt, requestResume]); + + useEffect(() => { + if (!opened) { + setEpisodes([]); + setEpisodesLoading(false); + setEpisodePlayLoadingId(null); + return; + } + if (activeItem?.item_type === 'show') { + void loadEpisodes(); + } else { + setEpisodes([]); + } + }, [opened, activeItem?.id, activeItem?.item_type, loadEpisodes]); + + const castPeople = useMemo(() => { + if (!activeItem?.cast) { + return []; + } + return activeItem.cast + .map((entry, index) => { + if (!entry) { + return null; + } + if (typeof entry === 'string') { + return { + key: `${entry}-${index}`, + name: entry, + role: null, + profile: null, + }; + } + const name = entry.name || entry.character || entry.job; + if (!name) { + return null; + } + return { + key: `${name}-${index}`, + name, + role: entry.character || entry.role || entry.job || null, + profile: entry.profile_url || entry.profile || null, + }; + }) + .filter(Boolean); + }, [activeItem]); + + const crewPeople = useMemo(() => { + if (!activeItem?.crew) { + return []; + } + return activeItem.crew + .map((entry, index) => { + if (!entry) { + return null; + } + if (typeof entry === 'string') { + return { + key: `${entry}-${index}`, + name: entry, + role: null, + profile: null, + }; + } + const name = entry.name || entry.job || entry.department; + if (!name) { + return null; + } + return { + key: `${name}-${index}`, + name, + role: entry.job || entry.department || entry.role || null, + profile: entry.profile_url || entry.profile || null, + }; + }) + .filter(Boolean); + }, [activeItem]); const handleStartPlayback = async (mode = 'start') => { if (!activeItem) return; - if (!selectedFileId && activeItem.files?.length) { - setSelectedFileId(activeItem.files[0].id); + const fileId = activeItem.files?.[0]?.id; + if (!fileId) { + notifications.show({ + title: 'Playback unavailable', + message: 'No media file is linked to this item yet.', + color: 'red', + }); + return; } - const fileId = selectedFileId || activeItem.files?.[0]?.id; setResumeMode(mode); setStartingPlayback(true); try { @@ -144,8 +311,49 @@ const MediaDetailModal = ({ opened, onClose }) => { setStartingPlayback(false); } }; + const primaryButtonLabel = useMemo(() => { + if (!activeItem) { + return 'Play'; + } + if (activeItem.item_type === 'show') { + if (showWatchSummary?.status === 'in_progress') { + return 'Continue Watching'; + } + if (showWatchSummary?.status === 'watched') { + return 'Watch Again'; + } + return 'Play'; + } + if (canResume && (resumePrompt || activeProgress)) { + return 'Continue Watching'; + } + return 'Play'; + }, [activeItem, showWatchSummary, canResume, resumePrompt, activeProgress]); - const onPlayClick = () => { + const handlePrimaryAction = () => { + if (!activeItem) return; + if (activeItem.item_type === 'show') { + const targetEpisode = + playbackPlan?.resumeEpisode || + playbackPlan?.nextEpisode || + playbackPlan?.sorted?.[0]; + if (!targetEpisode) { + notifications.show({ + title: 'No episodes available', + message: 'This series does not have any episodes to play yet.', + color: 'yellow', + }); + return; + } + const startIndex = playbackPlan.sorted.findIndex( + (ep) => ep.id === targetEpisode.id + ); + void handlePlayEpisode(targetEpisode, { + sequence: playbackPlan.sorted, + startIndex, + }); + return; + } if (canResume && (resumePrompt || activeProgress)) { setResumeMode('resume'); setResumeModalOpen(true); @@ -154,61 +362,179 @@ const MediaDetailModal = ({ opened, onClose }) => { } }; - const handleMarkWatched = async () => { - if (!activeItem) return; - setMarkingWatched(true); + const handlePlayEpisode = async ( + episode, + { sequence = orderedEpisodes, startIndex = null } = {} + ) => { + if (!episode) return; + setEpisodePlayLoadingId(episode.id); try { - await API.markMediaItemWatched(activeItem.id); - let duration = - activeItem.runtime_ms || activeItem.files?.[0]?.duration_ms || 0; - if (!duration) { - duration = 1000; + const episodeDetail = await API.getMediaItem(episode.id); + const episodeFileId = episodeDetail.files?.[0]?.id; + if (!episodeFileId) { + notifications.show({ + title: 'Playback unavailable', + message: 'No media file is linked to this episode yet.', + color: 'red', + }); + return; } - const progressData = { - id: activeProgress?.id || null, - position_ms: duration, - duration_ms: duration, - completed: true, - percentage: duration ? 1 : 0, - }; - setActiveProgress(progressData); - notifications.show({ - title: 'Progress updated', - message: 'Marked as watched.', - color: 'green', + + const streamInfo = await API.streamMediaItem(episodeDetail.id, { + fileId: episodeFileId, + }); + const playbackUrl = streamInfo?.url || streamInfo?.stream_url; + if (!playbackUrl) { + notifications.show({ + title: 'Playback error', + message: 'Streaming endpoint did not return a playable URL.', + color: 'red', + }); + return; + } + + const resolvedSequence = Array.isArray(sequence) ? sequence : []; + const episodeIds = resolvedSequence.length + ? resolvedSequence.map((ep) => ep.id) + : orderedEpisodes.map((ep) => ep.id); + const computedIndex = startIndex ?? episodeIds.findIndex((id) => id === episode.id); + + const playbackSequence = episodeIds.length + ? { + episodeIds, + currentIndex: computedIndex >= 0 ? computedIndex : 0, + } + : null; + + const episodeProgress = episodeDetail.watch_progress; + const episodeSummary = episodeDetail.watch_summary; + const resumePositionMs = + episodeSummary?.status === 'in_progress' + ? episodeSummary.position_ms || 0 + : episodeProgress?.position_ms || 0; + + const durationMs = + episodeSummary?.duration_ms || episodeProgress?.duration_ms || episodeDetail.runtime_ms; + + showVideo(playbackUrl, 'library', { + mediaItemId: episodeDetail.id, + mediaTitle: episodeDetail.title, + showId: activeItem?.id, + showTitle: activeItem?.title, + name: episodeDetail.title, + year: episodeDetail.release_year, + logo: + episodeDetail.poster_url + ? { url: episodeDetail.poster_url } + : activeItem?.poster_url + ? { url: activeItem.poster_url } + : undefined, + showPoster: activeItem?.poster_url, + progressId: episodeProgress?.id, + resumePositionMs, + durationMs, + fileId: episodeFileId, + playbackSequence, }); } catch (error) { - console.error('Failed to mark watched', error); + console.error('Failed to play episode', error); notifications.show({ - title: 'Error', - message: 'Unable to mark as watched.', + title: 'Playback error', + message: 'Unable to start playback for this episode.', color: 'red', }); } finally { - setMarkingWatched(false); + setEpisodePlayLoadingId(null); } }; - const handleClearProgress = async () => { - if (!activeItem) return; - setClearingProgress(true); + const episodesBySeason = useMemo(() => { + const grouped = new Map(); + orderedEpisodes.forEach((episode) => { + const season = episode.season_number || 1; + if (!grouped.has(season)) { + grouped.set(season, []); + } + grouped.get(season).push(episode); + }); + return grouped; + }, [orderedEpisodes]); + + const sortedSeasons = useMemo(() => { + return Array.from(episodesBySeason.keys()).sort((a, b) => a - b); + }, [episodesBySeason]); + + const formatEpisodeCode = (episode) => { + const season = episode.season_number || 0; + const ep = episode.episode_number || 0; + if (!season && !ep) { + return ''; + } + if (!season) { + return `E${ep.toString().padStart(2, '0')}`; + } + if (!ep) { + return `S${season.toString().padStart(2, '0')}`; + } + return `S${season.toString().padStart(2, '0')}E${ep.toString().padStart(2, '0')}`; + }; + + const handleEpisodeMarkWatched = async (episode) => { + if (!episode) return; + setEpisodeLoading(episode.id, 'watch'); try { - await API.clearMediaItemProgress(activeItem.id); - setActiveProgress(null); + await API.markMediaItemWatched(episode.id); + await refreshActiveItem(); + await loadEpisodes(); notifications.show({ - title: 'Progress cleared', - message: 'Watch progress removed.', + title: 'Episode updated', + message: `${episode.title} marked as watched.`, color: 'green', }); } catch (error) { - console.error('Failed to clear progress', error); + console.error('Failed to mark episode watched', error); + } finally { + clearEpisodeLoading(episode.id); + } + }; + + const handleEpisodeMarkUnwatched = async (episode) => { + if (!episode) return; + setEpisodeLoading(episode.id, 'unwatch'); + try { + await API.clearMediaItemProgress(episode.id); + await refreshActiveItem(); + await loadEpisodes(); notifications.show({ - title: 'Error', - message: 'Unable to clear progress.', + title: 'Episode updated', + message: `${episode.title} marked as unwatched.`, + color: 'blue', + }); + } catch (error) { + console.error('Failed to clear episode progress', error); + } finally { + clearEpisodeLoading(episode.id); + } + }; + + const handleEpisodeDelete = async (episode) => { + if (!episode) return; + if (!window.confirm(`Delete episode "${episode.title}"?`)) return; + setEpisodeLoading(episode.id, 'delete'); + try { + await API.deleteMediaItem(episode.id); + useMediaLibraryStore.getState().removeItems(episode.id); + await refreshActiveItem(); + await loadEpisodes(); + notifications.show({ + title: 'Episode deleted', + message: `${episode.title} removed from the library.`, color: 'red', }); + } catch (error) { + console.error('Failed to delete episode', error); } finally { - setClearingProgress(false); + clearEpisodeLoading(episode.id); } }; @@ -235,17 +561,36 @@ const MediaDetailModal = ({ opened, onClose }) => { Select a media item to see its details. ) : ( - - - {activeItem.poster_url ? ( - - ) : null} - + + {activeItem.poster_url ? ( + + + + + + ) : null} + + + {activeItem.title} @@ -260,6 +605,16 @@ const MediaDetailModal = ({ opened, onClose }) => { {activeItem.item_type} + {activeItem.item_type === 'show' && + showWatchSummary?.status === 'watched' && ( + Watched + )} + {activeItem.item_type === 'show' && + showWatchSummary?.status === 'in_progress' && ( + + In progress + + )} @@ -293,36 +648,29 @@ const MediaDetailModal = ({ opened, onClose }) => { )} - + {activeItem.item_type === 'show' && + showWatchSummary?.total_episodes ? ( + + {showWatchSummary.completed_episodes || 0} of{' '} + {showWatchSummary.total_episodes} episodes watched + + ) : null} + + } - onClick={onPlayClick} - loading={startingPlayback} + onClick={handlePrimaryAction} + loading={ + activeItem?.item_type === 'show' + ? episodePlayLoadingId !== null + : startingPlayback + } > - Play + {primaryButtonLabel} - } - onClick={handleMarkWatched} - loading={markingWatched} - > - Mark watched - - {activeProgress && ( - - - - )} - {canResume && ( + {canResume && activeItem?.item_type !== 'show' && ( - Resume available at{' '} + Resume at{' '} {runtimeLabel( resumePrompt?.position_ms || activeProgress?.position_ms @@ -343,127 +691,255 @@ const MediaDetailModal = ({ opened, onClose }) => { - - + {activeItem.item_type === 'show' && ( + <> + + {episodesLoading ? ( + + + + ) : sortedSeasons.length === 0 ? ( + + No episodes discovered yet. + + ) : ( + + {sortedSeasons.map((season) => { + const seasonEpisodes = episodesBySeason.get(season) || []; + return ( + + + + Season {season} + + {seasonEpisodes.length} episode + {seasonEpisodes.length === 1 ? '' : 's'} + + + + + {seasonEpisodes.map((episode) => { + const episodeProgress = episode.watch_progress; + const episodeStatus = episode.watch_summary?.status; + const progressPercent = episodeProgress?.percentage + ? Math.round(episodeProgress.percentage * 100) + : null; + const isWatched = episodeStatus === 'watched'; + const isInProgress = episodeStatus === 'in_progress'; + const episodeLoading = episodeActionLoading[episode.id]; + return ( + + + + + {[formatEpisodeCode(episode), episode.title] + .filter(Boolean) + .join(' ')} + + + {isWatched && ( + + Watched + + )} + {isInProgress && ( + + In progress + + )} + {progressPercent !== null && ( + + {progressPercent}% + + )} + + + + {runtimeLabel(episode.runtime_ms) && ( + + + + {runtimeLabel(episode.runtime_ms)} + + + )} + + {(episode.synopsis || activeItem?.synopsis) && ( + + {episode.synopsis || activeItem?.synopsis} + + )} + + + + } + onClick={() => + handlePlayEpisode(episode, { + sequence: playbackPlan?.sorted, + }) + } + loading={episodePlayLoadingId === episode.id} + > + Play + + : + } + onClick={() => + isWatched + ? handleEpisodeMarkUnwatched(episode) + : handleEpisodeMarkWatched(episode) + } + loading={episodeLoading === 'watch' || episodeLoading === 'unwatch'} + > + {isWatched ? 'Unwatch' : 'Mark watched'} + + handleEpisodeDelete(episode)} + loading={episodeLoading === 'delete'} + title="Delete episode" + > + + + + + + ); + })} + + + ); + })} + + )} + > + )} - {files.length === 0 ? ( - No media files linked yet. - ) : ( - - - - File - Codec - Resolution - Duration - Bitrate - - - - {files.map((file) => ( - setSelectedFileId(file.id)} - style={{ - cursor: 'pointer', - backgroundColor: - selectedFileId === file.id - ? 'rgba(99, 102, 241, 0.1)' - : undefined, - }} + + + + + {activeItem.tmdb_id && ( + } > - - - - - {file.file_name} - - - {file.relative_path} - - - - - - - Video: {file.video_codec || 'n/a'} - - - Audio: {file.audio_codec || 'n/a'} - - - - - {file.width && file.height - ? `${file.width}x${file.height}` - : '—'} - - - {runtimeLabel(file.duration_ms) || '—'} - - - {file.bit_rate - ? `${(file.bit_rate / 1000000).toFixed(2)} Mbps` - : '—'} - - - ))} - - - )} + TMDB {activeItem.tmdb_id} + + )} + {activeItem.imdb_id && ( + } + > + IMDB {activeItem.imdb_id} + + )} + - - - - - {activeItem.tmdb_id && ( - } - > - TMDB {activeItem.tmdb_id} - + {castPeople.length > 0 && ( + + Cast + + + {castPeople.map((person) => ( + + + {!person.profile && person.name?.[0]} + + + {person.name} + + {person.role && ( + + {person.role} + + )} + + ))} + + + )} - {activeItem.imdb_id && ( - } - > - IMDB {activeItem.imdb_id} - + + {crewPeople.length > 0 && ( + + Crew + + + {crewPeople.map((person) => ( + + + {!person.profile && person.name?.[0]} + + + {person.name} + + {person.role && ( + + {person.role} + + )} + + ))} + + + )} - - - {activeItem.cast && activeItem.cast.length > 0 && ( - - Cast - - {activeItem.cast.map((person) => ( - {person} - ))} - - - )} - - {activeItem.crew && activeItem.crew.length > 0 && ( - - Crew - - {activeItem.crew.map((person) => ( - {person} - ))} - - - )} + - + )} diff --git a/frontend/src/components/library/MediaGrid.jsx b/frontend/src/components/library/MediaGrid.jsx index 3a03dd99..c60a8efd 100644 --- a/frontend/src/components/library/MediaGrid.jsx +++ b/frontend/src/components/library/MediaGrid.jsx @@ -1,25 +1,31 @@ import React from 'react'; -import { - Badge, - Box, - Card, - Group, - Image, - Loader, - RingProgress, - SimpleGrid, - Stack, - Text, -} from '@mantine/core'; -import { Film, Library, Tv2 } from 'lucide-react'; +import { Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'; +import MediaCard from './MediaCard'; -const typeIcon = { - movie: , - episode: , - show: , +const groupItemsByLetter = (items) => { + const map = new Map(); + items.forEach((item) => { + const name = item.sort_title || item.title || ''; + const firstChar = name.charAt(0).toUpperCase(); + const key = /[A-Z]/.test(firstChar) ? firstChar : '#'; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(item); + }); + return map; }; -const MediaGrid = ({ items, loading, onSelect }) => { +const MediaGrid = ({ + items, + loading, + onSelect, + onContextMenu, + groupByLetter = false, + letterRefs, + columns = { base: 1, sm: 2, md: 4, lg: 5 }, + cardSize = 'md', +}) => { if (loading) { return ( @@ -31,94 +37,55 @@ const MediaGrid = ({ items, loading, onSelect }) => { if (!items || items.length === 0) { return ( - No media found. Adjust filters or trigger a scan. + No media found. ); } - return ( - - {items.map((item) => ( - onSelect(item)} - > - - - {item.poster_url ? ( - - ) : ( - - {typeIcon[item.item_type] || } - - )} - {item.watch_progress && item.watch_progress.percentage ? ( - - {Math.round(item.watch_progress.percentage * 100)}% - - } - /> - ) : null} - - - - - {item.title} - - {item.release_year && ( - - {item.release_year} - - )} - - - - {item.item_type} - - - {item.status} - - - {item.runtime_ms && ( - - {(item.runtime_ms / 60000).toFixed(0)} min - - )} + if (groupByLetter) { + const grouped = groupItemsByLetter(items); + const sortedKeys = Array.from(grouped.keys()).sort(); + return ( + + {sortedKeys.map((letter) => { + const refCallback = (el) => { + if (letterRefs && el) { + letterRefs.current[letter] = el; + } + }; + return ( + + + {letter} + + + {grouped.get(letter).map((item) => ( + + ))} + - - + ); + })} + + ); + } + + return ( + + {items.map((item) => ( + ))} ); diff --git a/frontend/src/pages/Library.jsx b/frontend/src/pages/Library.jsx index eb1edee6..e5e3c621 100644 --- a/frontend/src/pages/Library.jsx +++ b/frontend/src/pages/Library.jsx @@ -1,175 +1,267 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { ActionIcon, Box, Button, + Divider, Group, - Loader, - Pagination, - SegmentedControl, - SimpleGrid, + Paper, + Portal, + Select, Stack, Text, TextInput, Title, + SegmentedControl, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useDebouncedValue } from '@mantine/hooks'; -import { Filter, ListChecks, Plus, RefreshCcw, ServerOff } from 'lucide-react'; +import { + ListChecks, + Play, + Plus, + RefreshCcw, + Search, + Trash2, +} from 'lucide-react'; import useLibraryStore from '../store/library'; import useMediaLibraryStore from '../store/mediaLibrary'; -import LibraryCard from '../components/library/LibraryCard'; import LibraryFormModal from '../components/library/LibraryFormModal'; -import MediaGrid from '../components/library/MediaGrid'; import MediaDetailModal from '../components/library/MediaDetailModal'; import LibraryScanDrawer from '../components/library/LibraryScanDrawer'; +import MediaCarousel from '../components/library/MediaCarousel'; +import MediaGrid from '../components/library/MediaGrid'; +import AlphabetSidebar from '../components/library/AlphabetSidebar'; +import API from '../api'; -const statusOptions = [ - { value: 'all', label: 'All statuses' }, - { value: 'pending', label: 'Pending' }, - { value: 'matched', label: 'Matched' }, - { value: 'failed', label: 'Needs attention' }, +const TABS = [ + { label: 'Recommended', value: 'recommended' }, + { label: 'Library', value: 'library' }, + { label: 'Categories', value: 'categories' }, ]; -const LibraryPage = () => { - const [formOpen, setFormOpen] = useState(false); - const [editingLibrary, setEditingLibrary] = useState(null); - const [scanDrawerOpen, setScanDrawerOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearch] = useDebouncedValue(searchTerm, 400); - const [librarySearch, setLibrarySearch] = useState(''); - const [debouncedLibrarySearch] = useDebouncedValue(librarySearch, 400); - const [playbackModalOpen, setPlaybackModalOpen] = useState(false); - const [formSubmitting, setFormSubmitting] = useState(false); +const SORT_OPTIONS = [ + { label: 'Default', value: 'default' }, + { label: 'Name (A-Z)', value: 'alpha' }, + { label: 'Release Year', value: 'year' }, + { label: 'Recently Added', value: 'recent' }, + { label: 'Genre', value: 'genre' }, +]; - // ---- Library store (one selector per field/action) ---- +const parseDate = (value) => { + if (!value) return 0; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? 0 : timestamp; +}; + +const LibraryPage = () => { + const navigate = useNavigate(); + const { mediaType } = useParams(); + const normalizedMediaType = mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null; + + useEffect(() => { + if (!normalizedMediaType) { + navigate('/library/movies', { replace: true }); + } + }, [normalizedMediaType, navigate]); + + const isMovies = normalizedMediaType !== 'shows'; + const itemTypeFilter = isMovies ? 'movie' : 'show'; + + const [formOpen, setFormOpen] = useState(false); + const [scanDrawerOpen, setScanDrawerOpen] = useState(false); + const [playbackModalOpen, setPlaybackModalOpen] = useState(false); + const [activeTab, setActiveTab] = useState('recommended'); + const [sortOption, setSortOption] = useState('default'); + const [searchTerm, setSearchTerm] = useState(''); + const [contextMenu, setContextMenu] = useState(null); + const contextMenuPosition = useMemo(() => { + if (!contextMenu) return null; + if (typeof window === 'undefined') { + return { top: contextMenu.y, left: contextMenu.x }; + } + return { + top: Math.min(contextMenu.y, window.innerHeight - 220), + left: Math.min(contextMenu.x, window.innerWidth - 260), + }; + }, [contextMenu]); + + const [debouncedSearch] = useDebouncedValue(searchTerm, 350); + + // Library store hooks const libraries = useLibraryStore((s) => s.libraries); const librariesLoading = useLibraryStore((s) => s.loading); const fetchLibraries = useLibraryStore((s) => s.fetchLibraries); const createLibrary = useLibraryStore((s) => s.createLibrary); - const updateLibrary = useLibraryStore((s) => s.updateLibrary); - const deleteLibrary = useLibraryStore((s) => s.deleteLibrary); const triggerScan = useLibraryStore((s) => s.triggerScan); const selectedLibraryId = useLibraryStore((s) => s.selectedLibraryId); const setSelectedLibrary = useLibraryStore((s) => s.setSelectedLibrary); - const libraryFilters = useLibraryStore((s) => s.filters); - const setLibraryFilters = useLibraryStore((s) => s.setFilters); - // ---- Media store (one selector per field/action) ---- + // Media store hooks const items = useMediaLibraryStore((s) => s.items); const itemsLoading = useMediaLibraryStore((s) => s.loading); - const total = useMediaLibraryStore((s) => s.total); - const page = useMediaLibraryStore((s) => s.page); - const pageSize = useMediaLibraryStore((s) => s.pageSize); - const setPage = useMediaLibraryStore((s) => s.setPage); - const itemFilters = useMediaLibraryStore((s) => s.filters); const setItemFilters = useMediaLibraryStore((s) => s.setFilters); const fetchItems = useMediaLibraryStore((s) => s.fetchItems); + const setSelectedMediaLibrary = useMediaLibraryStore((s) => s.setSelectedLibraryId); const openItem = useMediaLibraryStore((s) => s.openItem); const closeItem = useMediaLibraryStore((s) => s.closeItem); + const removeItems = useMediaLibraryStore((s) => s.removeItems); + const upsertItems = useMediaLibraryStore((s) => s.upsertItems); - // ---- Effects ---- - - // Initial library load (run once on mount) + // Fetch libraries on mount useEffect(() => { fetchLibraries(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [fetchLibraries]); - // Debounced media search change -> update filters & reset page - useEffect(() => { - setItemFilters({ search: debouncedSearch }); - setPage(1); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearch]); - - // Debounced library search change -> update filters & refresh libraries - useEffect(() => { - setLibraryFilters({ search: debouncedLibrarySearch }); - fetchLibraries(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedLibrarySearch]); - - // Fetch items when selected library or paging/filter inputs change + // Ensure a library is selected for the current media type useEffect(() => { + if (!libraries || libraries.length === 0) return; if (selectedLibraryId) { - fetchItems(selectedLibraryId); + const current = libraries.find((lib) => lib.id === selectedLibraryId); + if (current) return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - selectedLibraryId, - page, - pageSize, - itemFilters.type, - itemFilters.status, - itemFilters.year, - itemFilters.search, - ]); + + const preferred = libraries.find((lib) => + isMovies + ? lib.library_type === 'movies' || lib.library_type === 'mixed' + : lib.library_type === 'shows' || lib.library_type === 'mixed' + ); + + if (preferred) { + setSelectedLibrary(preferred.id); + } else if (libraries.length > 0) { + setSelectedLibrary(libraries[0].id); + } + }, [libraries, selectedLibraryId, setSelectedLibrary, isMovies]); + + // Sync media filters with current type and search + useEffect(() => { + setItemFilters({ + type: itemTypeFilter, + search: debouncedSearch, + }); + }, [itemTypeFilter, debouncedSearch, setItemFilters]); + + // Fetch items when library changes or filters update + useEffect(() => { + if (!selectedLibraryId) return; + setSelectedMediaLibrary(selectedLibraryId); + fetchItems(selectedLibraryId); + }, [selectedLibraryId, fetchItems, setSelectedMediaLibrary, debouncedSearch, itemTypeFilter]); const selectedLibrary = useMemo( () => libraries.find((lib) => lib.id === selectedLibraryId) || null, [libraries, selectedLibraryId] ); - // ---- Handlers ---- - const handleCreateOrUpdate = async (payload) => { - setFormSubmitting(true); - try { - if (editingLibrary) { - await updateLibrary(editingLibrary.id, payload); - notifications.show({ - title: 'Library updated', - message: 'Changes saved successfully.', - color: 'green', - }); - } else { - await createLibrary(payload); - notifications.show({ - title: 'Library created', - message: 'New library added.', - color: 'green', - }); + const filteredItems = useMemo(() => { + const typeFiltered = items.filter((item) => item.item_type === itemTypeFilter); + if (!debouncedSearch) return typeFiltered; + const query = debouncedSearch.toLowerCase(); + return typeFiltered.filter((item) => + (item.title || '').toLowerCase().includes(query) + ); + }, [items, itemTypeFilter, debouncedSearch]); + + const continueWatching = useMemo(() => { + return filteredItems + .filter((item) => { + if (item.item_type === 'show') { + return item.watch_summary?.status === 'in_progress'; + } + const progress = item.watch_progress; + return progress && !progress.completed; + }) + .sort((a, b) => { + const aTime = parseDate(a.watch_progress?.last_watched_at || a.updated_at); + const bTime = parseDate(b.watch_progress?.last_watched_at || b.updated_at); + return bTime - aTime; + }) + .slice(0, 20); + }, [filteredItems]); + + const recentlyReleased = useMemo(() => { + return [...filteredItems] + .filter((item) => item.release_year) + .sort((a, b) => (b.release_year || 0) - (a.release_year || 0)) + .slice(0, 30); + }, [filteredItems]); + + const recentlyAdded = useMemo(() => { + return [...filteredItems] + .sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at)) + .slice(0, 30); + }, [filteredItems]); + + const genresMap = useMemo(() => { + const map = new Map(); + filteredItems.forEach((item) => { + const genres = Array.isArray(item.genres) ? item.genres : []; + if (genres.length === 0) return; + const primary = genres[0]; + if (!map.has(primary)) { + map.set(primary, []); } - setFormOpen(false); - setEditingLibrary(null); - fetchLibraries(); - } catch (error) { - console.error('Failed to save library', error); - } finally { - setFormSubmitting(false); + map.get(primary).push(item); + }); + return map; + }, [filteredItems]); + + const genreCarousels = useMemo(() => { + const entries = Array.from(genresMap.entries()); + return entries + .map(([genre, genreItems]) => ({ + genre, + items: genreItems + .slice() + .sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at)) + .slice(0, 25), + })) + .filter((entry) => entry.items.length > 0) + .slice(0, 12); + }, [genresMap]); + + const sortedLibraryItems = useMemo(() => { + switch (sortOption) { + case 'alpha': + return [...filteredItems].sort((a, b) => { + const aTitle = (a.sort_title || a.title || '').toLowerCase(); + const bTitle = (b.sort_title || b.title || '').toLowerCase(); + return aTitle.localeCompare(bTitle); + }); + case 'year': + return [...filteredItems].sort((a, b) => (b.release_year || 0) - (a.release_year || 0)); + case 'recent': + return [...filteredItems].sort((a, b) => parseDate(b.first_imported_at) - parseDate(a.first_imported_at)); + default: + return filteredItems; + } + }, [filteredItems, sortOption]); + + const availableLetters = useMemo(() => { + if (sortOption !== 'alpha') return new Set(); + const letters = new Set(); + sortedLibraryItems.forEach((item) => { + const name = item.sort_title || item.title || ''; + const firstChar = name.charAt(0).toUpperCase(); + const key = /[A-Z]/.test(firstChar) ? firstChar : '#'; + letters.add(key); + }); + return letters; + }, [sortedLibraryItems, sortOption]); + + const letterRefs = useRef({}); + + const handleLetterSelect = (letter) => { + const node = letterRefs.current[letter]; + if (node && node.scrollIntoView) { + node.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; - const handleDelete = async (library) => { - if (!window.confirm(`Delete library "${library.name}"?`)) return; - try { - await deleteLibrary(library.id); - notifications.show({ - title: 'Library removed', - message: 'The library has been deleted.', - color: 'green', - }); - } catch (error) { - console.error('Failed to delete library', error); - } - }; - - const handleScan = async (id) => { - try { - await triggerScan(id, { full: false }); - notifications.show({ - title: 'Scan started', - message: 'Library scan has been queued.', - color: 'blue', - }); - setScanDrawerOpen(true); - } catch (error) { - console.error('Failed to start scan', error); - } - }; - - const onMediaSelect = async (item) => { + const handleOpenItem = async (item) => { try { await openItem(item.id); setPlaybackModalOpen(true); @@ -182,37 +274,275 @@ const LibraryPage = () => { } }; - // ---- Render ---- + const refreshItem = async (id) => { + try { + const data = await API.getMediaItem(id); + upsertItems([data]); + } catch (error) { + console.error('Failed to refresh media item', error); + } + }; + + const handleMarkWatched = async (item) => { + try { + if (item.item_type === 'show') { + const response = await API.markSeriesWatched(item.id); + if (response?.item) { + upsertItems([response.item]); + } else { + await refreshItem(item.id); + } + notifications.show({ + title: 'Series updated', + message: 'All episodes marked as watched.', + color: 'green', + }); + } else { + await API.markMediaItemWatched(item.id); + await refreshItem(item.id); + notifications.show({ + title: 'Marked as watched', + message: `${item.title} marked as watched.`, + color: 'green', + }); + } + } catch (error) { + console.error('Failed to mark watched', error); + notifications.show({ + title: 'Action failed', + message: 'Unable to mark item as watched at this time.', + color: 'red', + }); + } + }; + + const handleMarkUnwatched = async (item) => { + try { + if (item.item_type === 'show') { + const response = await API.markSeriesUnwatched(item.id); + if (response?.item) { + upsertItems([response.item]); + } else { + await refreshItem(item.id); + } + notifications.show({ + title: 'Series updated', + message: 'Watch history cleared.', + color: 'blue', + }); + } else { + await API.clearMediaItemProgress(item.id); + await refreshItem(item.id); + notifications.show({ + title: 'Progress cleared', + message: `${item.title} marked as unwatched.`, + color: 'blue', + }); + } + } catch (error) { + console.error('Failed to mark unwatched', error); + notifications.show({ + title: 'Action failed', + message: 'Unable to update watch state right now.', + color: 'red', + }); + } + }; + + const handleDeleteItem = async (item) => { + const label = item.item_type === 'show' ? 'series and all episodes' : 'media item'; + if (!window.confirm(`Delete ${item.title}? This will remove the ${label} from your library.`)) { + return; + } + try { + await API.deleteMediaItem(item.id); + removeItems(item.id); + notifications.show({ + title: 'Deleted', + message: `${item.title} removed from your library.`, + color: 'red', + }); + } catch (error) { + console.error('Failed to delete media item', error); + notifications.show({ + title: 'Delete failed', + message: 'Unable to delete this item right now.', + color: 'red', + }); + } + }; + + const handleContextMenu = (event, item) => { + event.preventDefault(); + setContextMenu({ + x: event.clientX, + y: event.clientY, + item, + }); + }; + + useEffect(() => { + if (!contextMenu) return; + const handleOutsideClick = () => setContextMenu(null); + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + }, [contextMenu]); + + const handleScanLibrary = async () => { + if (!selectedLibraryId) return; + try { + await triggerScan(selectedLibraryId, { full: false }); + notifications.show({ + title: 'Scan started', + message: 'Library scan has been queued.', + color: 'blue', + }); + setScanDrawerOpen(true); + } catch (error) { + console.error('Failed to start scan', error); + notifications.show({ + title: 'Scan failed', + message: 'Unable to start scan at this time.', + color: 'red', + }); + } + }; + + const recommendedView = ( + + + + + + ); + + const libraryView = (() => { + if (sortOption === 'alpha') { + letterRefs.current = {}; + } + return ( + + + + setSortOption(value || 'default')} + w={220} + /> + } + onClick={() => fetchItems(selectedLibraryId)} + > + Refresh + + + {sortOption === 'default' ? ( + recommendedView + ) : sortOption === 'genre' ? ( + + {genreCarousels.map(({ genre, items: genreItems }) => ( + + ))} + + ) : ( + + {sortOption === 'alpha' && availableLetters.size > 0 && ( + + )} + + + )} + + + ); + })(); + + const categoriesView = ( + + {genreCarousels.length === 0 ? ( + No categories available. + ) : ( + genreCarousels.map(({ genre, items: genreItems }) => ( + + )) + )} + + ); + + const contextItem = contextMenu?.item; + const contextStatus = contextItem?.watch_summary?.status; + return ( - + - - - Media Libraries + + + {isMovies ? 'Movies' : 'TV Shows'} - Create libraries, trigger scans, and manage your offline media collection. + {selectedLibrary ? selectedLibrary.name : 'Select a library to begin.'} - - + + + ({ + value: String(library.id), + label: library.name, + }))} + value={selectedLibraryId ? String(selectedLibraryId) : null} + onChange={(value) => setSelectedLibrary(Number(value))} + w={220} + disabled={librariesLoading || libraries.length === 0} + /> } - onClick={() => { - setEditingLibrary(null); - setFormOpen(true); - }} + onClick={() => setFormOpen(true)} > - New Library + Add Library fetchLibraries()} - title="Refresh libraries" - > - - - setScanDrawerOpen(true)} + color="blue" + onClick={handleScanLibrary} title="View recent scans" > @@ -220,148 +550,58 @@ const LibraryPage = () => { - - } - value={librarySearch} - onChange={(event) => setLibrarySearch(event.currentTarget.value)} - /> + { - setLibraryFilters({ type: value }); - fetchLibraries(); - }} - data={[ - { label: 'All types', value: 'all' }, - { label: 'Movies', value: 'movies' }, - { label: 'Shows', value: 'shows' }, - { label: 'Mixed', value: 'mixed' }, - ]} - /> - { - setLibraryFilters({ autoScan: value }); - fetchLibraries(); - }} - data={[ - { label: 'Auto + Manual', value: 'all' }, - { label: 'Auto only', value: 'enabled' }, - { label: 'Manual', value: 'disabled' }, - ]} + value={activeTab} + onChange={setActiveTab} + data={TABS} /> + + } + placeholder="Search library" + value={searchTerm} + onChange={(event) => setSearchTerm(event.currentTarget.value)} + w={260} + /> + - {librariesLoading ? ( - - - - ) : libraries.length === 0 ? ( - - - No libraries yet. Create one to get started. - } onClick={() => setFormOpen(true)}> - Create your first library - - - ) : ( - - {libraries.map((library) => ( - { - setSelectedLibrary(id); - setPage(1); - }} - onEdit={(lib) => { - setEditingLibrary(lib); - setFormOpen(true); - }} - onDelete={handleDelete} - onScan={handleScan} - /> - ))} - - )} - {selectedLibrary ? ( - - - - {selectedLibrary.name} - - Browsing items in this library. - - - - } - value={searchTerm} - onChange={(event) => setSearchTerm(event.currentTarget.value)} - maw={260} - /> - { - setItemFilters({ type: value }); - setPage(1); - }} - data={[ - { label: 'All', value: 'all' }, - { label: 'Movies', value: 'movie' }, - { label: 'Shows', value: 'show' }, - { label: 'Episodes', value: 'episode' }, - ]} - /> - { - setItemFilters({ status: value }); - setPage(1); - }} - data={statusOptions} - /> - - - - - - {total > pageSize ? ( - - - Showing {(page - 1) * pageSize + 1}– - {Math.min(page * pageSize, total)} of {total} - - - - ) : null} - + + {activeTab === 'recommended' && recommendedView} + {activeTab === 'library' && libraryView} + {activeTab === 'categories' && categoriesView} + ) : ( - - Select a library to explore its content. - + + Select or create a media library to get started. + )} { - setFormOpen(false); - setEditingLibrary(null); + onClose={() => setFormOpen(false)} + library={null} + onSubmit={async (payload) => { + try { + const created = await createLibrary(payload); + notifications.show({ + title: 'Library created', + message: 'New library added successfully.', + color: 'green', + }); + setFormOpen(false); + fetchLibraries(); + if (created?.id) { + setSelectedLibrary(created.id); + } + } catch (error) { + console.error('Failed to create library', error); + } }} - library={editingLibrary} - onSubmit={handleCreateOrUpdate} - submitting={formSubmitting} + submitting={false} /> { closeItem(); }} /> + + {contextMenu && contextItem && contextMenuPosition && ( + + + + } + onClick={() => { + handleOpenItem(contextItem); + setContextMenu(null); + }} + > + {contextStatus === 'in_progress' ? 'Continue Watching' : 'Play'} + + } + onClick={async () => { + await API.refreshMediaItemMetadata(contextItem.id); + notifications.show({ + title: 'Metadata queued', + message: 'Metadata refresh has been requested.', + color: 'blue', + }); + setContextMenu(null); + }} + > + Refresh Metadata + + + {contextStatus === 'watched' ? ( + { + await handleMarkUnwatched(contextItem); + setContextMenu(null); + }} + > + Mark unwatched + + ) : ( + { + await handleMarkWatched(contextItem); + setContextMenu(null); + }} + > + Mark watched + + )} + } + onClick={async () => { + await handleDeleteItem(contextItem); + setContextMenu(null); + }} + > + Delete + + + + + )} ); }; diff --git a/frontend/src/store/library.jsx b/frontend/src/store/library.jsx index 43f3643b..3de8eff6 100644 --- a/frontend/src/store/library.jsx +++ b/frontend/src/store/library.jsx @@ -1,6 +1,23 @@ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import API from '../api'; +import useMediaLibraryStore from './mediaLibrary'; + +const normalizeScanEntry = (scan) => { + if (!scan) return scan; + let processed = scan.processed_files ?? scan.processed; + if (processed == null && scan.status === 'completed' && scan.total_files != null) { + processed = scan.total_files; + } + if (processed == null) { + processed = 0; + } + return { + ...scan, + processed, + processed_files: processed, + }; +}; const useLibraryStore = create( immer((set, get) => ({ @@ -93,8 +110,9 @@ const useLibraryStore = create( if (!state.scans[id]) { state.scans[id] = []; } - state.scans[id] = [response, ...(state.scans[id] || [])]; - state.scans['all'] = [response, ...(state.scans['all'] || [])]; + const normalized = normalizeScanEntry(response); + state.scans[id] = [normalized, ...(state.scans[id] || [])]; + state.scans['all'] = [normalized, ...(state.scans['all'] || [])]; }); return response; }, @@ -110,9 +128,10 @@ const useLibraryStore = create( } const response = await API.getLibraryScans(params); set((state) => { - state.scans[libraryId || 'all'] = Array.isArray(response) + const payload = Array.isArray(response) ? response : response.results || []; + state.scans[libraryId || 'all'] = payload.map((scan) => normalizeScanEntry(scan)); state.scansLoading = false; }); } catch (error) { @@ -129,9 +148,19 @@ const useLibraryStore = create( const scanId = event.scan_id; const libraryId = event.library_id || null; + if (event.media_item) { + useMediaLibraryStore.getState().upsertItems([event.media_item]); + } + const updateList = (list) => { const items = list ? [...list] : []; const index = items.findIndex((scan) => String(scan.id) === String(scanId)); + const processedValue = + event.processed_files ?? + event.processed ?? + items[index]?.processed_files ?? + items[index]?.processed ?? + 0; const updatedEntry = { id: scanId, library: libraryId, @@ -140,10 +169,12 @@ const useLibraryStore = create( summary: event.summary || event.message || items[index]?.summary || '', matched_items: event.matched ?? items[index]?.matched_items ?? null, unmatched_files: event.unmatched ?? items[index]?.unmatched_files ?? null, - total_files: event.files ?? items[index]?.total_files ?? null, + total_files: event.total ?? event.files ?? items[index]?.total_files ?? null, new_files: event.new_files ?? items[index]?.new_files ?? null, updated_files: event.updated_files ?? items[index]?.updated_files ?? null, removed_files: event.removed_files ?? items[index]?.removed_files ?? null, + processed: processedValue, + processed_files: processedValue, created_at: (items[index]?.created_at || new Date().toISOString()), finished_at: @@ -154,9 +185,9 @@ const useLibraryStore = create( }; if (index >= 0) { - items[index] = { ...items[index], ...updatedEntry }; + items[index] = normalizeScanEntry({ ...items[index], ...updatedEntry }); } else { - items.unshift(updatedEntry); + items.unshift(normalizeScanEntry(updatedEntry)); } return items; }; diff --git a/frontend/src/store/mediaLibrary.jsx b/frontend/src/store/mediaLibrary.jsx index 8019a680..eb37fe98 100644 --- a/frontend/src/store/mediaLibrary.jsx +++ b/frontend/src/store/mediaLibrary.jsx @@ -2,6 +2,34 @@ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import API from '../api'; +const METADATA_REFRESH_COOLDOWN_MS = 60 * 1000; +const metadataRequestCache = new Map(); + +const shouldRefreshMetadata = (item) => { + if (!item || !item.id) return false; + return !item.poster_url || !item.metadata_last_synced_at; +}; + +const queueMetadataRefresh = (items, { force = false } = {}) => { + if (!Array.isArray(items) || items.length === 0) return; + const now = Date.now(); + items.forEach((item) => { + if (!shouldRefreshMetadata(item)) { + if (item?.id) { + metadataRequestCache.delete(item.id); + } + return; + } + const lastRequested = metadataRequestCache.get(item.id) || 0; + if (!force && now - lastRequested < METADATA_REFRESH_COOLDOWN_MS) return; + metadataRequestCache.set(item.id, now); + API.refreshMediaItemMetadata(item.id).catch((error) => { + console.debug('Auto metadata refresh failed', error); + metadataRequestCache.delete(item.id); + }); + }); +}; + const initialFilters = { type: 'all', search: '', @@ -14,36 +42,74 @@ const useMediaLibraryStore = create( items: [], loading: false, error: null, - page: 1, - pageSize: 24, total: 0, activeItem: null, activeProgress: null, activeItemLoading: false, resumePrompt: null, + selectedLibraryId: null, filters: { ...initialFilters }, setFilters: (updated) => set((state) => { state.filters = { ...state.filters, ...updated }; - state.page = 1; }), - setPage: (page) => + setSelectedLibraryId: (libraryId) => set((state) => { - state.page = page; - }), - - setPageSize: (pageSize) => - set((state) => { - state.pageSize = pageSize; - state.page = 1; + state.selectedLibraryId = libraryId; }), resetFilters: () => set((state) => { state.filters = { ...initialFilters }; - state.page = 1; + }), + + upsertItems: (itemsToUpsert) => + set((state) => { + if (!Array.isArray(itemsToUpsert) || itemsToUpsert.length === 0) { + return; + } + + const selectedLibraryId = get().selectedLibraryId; + if (!selectedLibraryId) { + return; + } + + const byId = new Map(); + state.items.forEach((item) => { + byId.set(item.id, item); + }); + + itemsToUpsert.forEach((incoming) => { + if (!incoming || typeof incoming !== 'object' || !incoming.id) { + return; + } + if ( + incoming.library && + Number(incoming.library) !== Number(selectedLibraryId) + ) { + return; + } + const existing = byId.get(incoming.id) || {}; + byId.set(incoming.id, { ...existing, ...incoming }); + }); + + const sorted = Array.from(byId.values()).sort((a, b) => { + const aTitle = (a.sort_title || a.title || '').toLowerCase(); + const bTitle = (b.sort_title || b.title || '').toLowerCase(); + return aTitle.localeCompare(bTitle); + }); + + state.items = sorted; + state.total = sorted.length; + }), + + removeItems: (ids) => + set((state) => { + const idSet = new Set(Array.isArray(ids) ? ids : [ids]); + state.items = state.items.filter((item) => !idSet.has(item.id)); + state.total = state.items.length; }), fetchItems: async (libraryId) => { @@ -59,11 +125,9 @@ const useMediaLibraryStore = create( state.error = null; }); try { - const { page, pageSize, filters } = get(); + const { filters } = get(); const params = new URLSearchParams(); params.append('library', libraryId); - params.append('page', page); - params.append('page_size', pageSize); if (filters.type !== 'all') { params.append('item_type', filters.type); } @@ -83,6 +147,8 @@ const useMediaLibraryStore = create( state.total = response.count || results.length || 0; state.loading = false; }); + const itemsArray = Array.isArray(results) ? results : []; + queueMetadataRefresh(itemsArray); } catch (error) { console.error('Failed to fetch media items', error); set((state) => { @@ -106,6 +172,8 @@ const useMediaLibraryStore = create( state.activeItemLoading = false; state.activeProgress = progress; }); + get().upsertItems([response]); + queueMetadataRefresh([response], { force: true }); return response; } catch (error) { console.error('Failed to load media item', error);