From c16ab49d56fe0717e09c4054841c29223c140abd Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Wed, 17 Sep 2025 20:06:32 -0500 Subject: [PATCH] Just ignore this --- apps/media_library/api_views.py | 159 ++- apps/media_library/metadata.py | 45 +- .../0002_libraryscan_processed_files.py | 18 + apps/media_library/models.py | 55 +- apps/media_library/serializers.py | 184 +++- apps/media_library/tasks.py | 163 +++- apps/media_library/utils.py | 86 +- apps/media_library/views.py | 68 +- frontend/src/App.jsx | 3 +- frontend/src/WebSocket.jsx | 21 + frontend/src/api.js | 57 ++ frontend/src/components/FloatingVideo.jsx | 191 +++- frontend/src/components/Sidebar.jsx | 82 +- .../components/library/AlphabetSidebar.jsx | 129 +++ .../components/library/LibraryFormModal.jsx | 1 + .../components/library/LibraryScanDrawer.jsx | 36 +- frontend/src/components/library/MediaCard.jsx | 167 ++++ .../src/components/library/MediaCarousel.jsx | 109 +++ .../components/library/MediaDetailModal.jsx | 918 +++++++++++++----- frontend/src/components/library/MediaGrid.jsx | 169 ++-- frontend/src/pages/Library.jsx | 859 ++++++++++------ frontend/src/store/library.jsx | 43 +- frontend/src/store/mediaLibrary.jsx | 98 +- 23 files changed, 2948 insertions(+), 713 deletions(-) create mode 100644 apps/media_library/migrations/0002_libraryscan_processed_files.py create mode 100644 frontend/src/components/library/AlphabetSidebar.jsx create mode 100644 frontend/src/components/library/MediaCard.jsx create mode 100644 frontend/src/components/library/MediaCarousel.jsx 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 + + + + + + + + )} {/* 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 }) => setSortOption(value || 'default')} + w={220} + /> + + + {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.'} -
- +
+ +