mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
Just ignore this
This commit is contained in:
parent
5899f11b94
commit
c16ab49d56
23 changed files with 2948 additions and 713 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ const App = () => {
|
|||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
<Route path="/vods" element={<VODsPage />} />
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/library" element={<Navigate to="/library/movies" replace />} />
|
||||
<Route path="/library/:mediaType" element={<LibraryPage />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login needsSuperuser />} />
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{nextAutoplay && autoplayCountdown !== null && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.85)',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
color: 'white',
|
||||
width: 'calc(100% - 70px)',
|
||||
maxWidth: 260,
|
||||
zIndex: 6,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Text size="xs" fw={500} mb={6}>
|
||||
Next episode starts in {autoplayCountdown}s
|
||||
</Text>
|
||||
<Progress
|
||||
value={Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
((AUTOPLAY_SECONDS - autoplayCountdown) / AUTOPLAY_SECONDS) * 100
|
||||
)
|
||||
)}
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="cyan"
|
||||
/>
|
||||
<Group gap="xs" mt={6} justify="flex-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelAutoPlay();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="cyan"
|
||||
leftSection={<Play size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playNextEpisodeNow();
|
||||
}}
|
||||
>
|
||||
Play now
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Error message below video - doesn't block controls */}
|
||||
|
|
|
|||
|
|
@ -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: <Library size={20} />,
|
||||
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: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{
|
||||
label: 'Media Library',
|
||||
path: '/library',
|
||||
icon: <Library size={20} />,
|
||||
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 */}
|
||||
<Stack gap="xs" mt="lg">
|
||||
{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 (
|
||||
<Stack key={item.label} spacing={4}>
|
||||
<UnstyledButton
|
||||
className={`navlink ${expanded ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
|
||||
onClick={() => setLibraryExpanded((prev) => !prev)}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<Group justify="space-between" w="100%" gap={0}>
|
||||
<Text
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: collapsed ? 0 : 150,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Group>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
{expanded && !collapsed && (
|
||||
<Stack spacing={2} ml={32}>
|
||||
{item.children.map((child) => {
|
||||
const active = location.pathname.startsWith(child.path);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={child.path}
|
||||
component={Link}
|
||||
to={child.path}
|
||||
className={`navlink ${active ? 'navlink-active' : ''}`}
|
||||
style={{
|
||||
paddingLeft: 18,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{child.label}</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
|
|
|
|||
129
frontend/src/components/library/AlphabetSidebar.jsx
Normal file
129
frontend/src/components/library/AlphabetSidebar.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Stack, UnstyledButton, Text } from '@mantine/core';
|
||||
|
||||
const letters = ['#', 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
|
||||
|
||||
const HOTZONE_WIDTH = 20; // px: invisible strip you hover to reveal
|
||||
const IDLE_FADE_MS = 1500; // ms of no mouse movement before fade out
|
||||
const LETTER_GAP = 6; // must match Stack spacing
|
||||
const BASE_FONT_PX = 12; // Mantine "xs" ~ 12px by default
|
||||
const MAX_SCALE_BOOST = 0.35; // how large letters grow at the cursor
|
||||
const SIGMA_PX = 26; // controls falloff steepness
|
||||
|
||||
export default function AlphabetSidebar({
|
||||
available = new Set(),
|
||||
onSelect,
|
||||
top = 120,
|
||||
right = 16,
|
||||
}) {
|
||||
const [isHot, setIsHot] = useState(false); // pointer inside hot zone or sidebar
|
||||
const [isIdle, setIsIdle] = useState(false); // idle while still hovered
|
||||
const [mouseY, setMouseY] = useState(null); // y relative to sidebar
|
||||
const sidebarRef = useRef(null);
|
||||
const idleTimerRef = useRef(null);
|
||||
|
||||
// Reset idle timer whenever mouse moves inside the sidebar
|
||||
const poke = () => {
|
||||
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 */}
|
||||
<Box
|
||||
onMouseEnter={() => setIsHot(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
right: 0,
|
||||
width: HOTZONE_WIDTH,
|
||||
height: '70vh',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sidebar itself */}
|
||||
<Stack
|
||||
ref={sidebarRef}
|
||||
spacing={LETTER_GAP}
|
||||
align="center"
|
||||
onMouseEnter={() => 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 (
|
||||
<UnstyledButton
|
||||
key={letter}
|
||||
onClick={() => isEnabled && onSelect?.(letter)}
|
||||
style={{
|
||||
opacity: isEnabled ? 1 : 0.3,
|
||||
cursor: isEnabled ? 'pointer' : 'default',
|
||||
transform: `scale(${scale})`,
|
||||
transition: 'transform 80ms linear',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} lh={1}>
|
||||
{letter}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -129,6 +129,7 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
|
|||
<Select
|
||||
label="Library Type"
|
||||
data={LIBRARY_TYPES}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
{...form.getInputProps('library_type')}
|
||||
/>
|
||||
<NumberInput
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
|
|
@ -25,6 +26,7 @@ const statusColor = {
|
|||
running: 'blue',
|
||||
started: 'blue',
|
||||
discovered: 'indigo',
|
||||
progress: 'blue',
|
||||
completed: 'green',
|
||||
failed: 'red',
|
||||
cancelled: 'yellow',
|
||||
|
|
@ -66,7 +68,39 @@ const LibraryScanDrawer = ({ opened, onClose, libraryId }) => {
|
|||
bullet={<Badge color={statusColor[scan.status] || 'gray'}>{scan.status}</Badge>}
|
||||
>
|
||||
<Stack spacing={4} mt="xs">
|
||||
<Text size="sm">Summary: {scan.summary || 'No summary yet'}</Text>
|
||||
{(() => {
|
||||
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 (
|
||||
<Stack spacing={2}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={500}>
|
||||
{processed} / {total} processed
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{percent}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
size="md"
|
||||
striped
|
||||
animated={scan.status !== 'completed' && scan.status !== 'failed'}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
<Text size="sm">
|
||||
Summary: {scan.summary || 'No summary yet'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'}
|
||||
</Text>
|
||||
|
|
|
|||
167
frontend/src/components/library/MediaCard.jsx
Normal file
167
frontend/src/components/library/MediaCard.jsx
Normal file
|
|
@ -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: <Film size={18} />,
|
||||
episode: <Tv2 size={18} />,
|
||||
show: <LibraryIcon size={18} />,
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', background: 'rgba(12, 15, 27, 0.75)' }}
|
||||
onClick={() => onClick?.(item)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{item.poster_url ? (
|
||||
<Box
|
||||
h={height}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: 'rgba(12, 17, 32, 0.65)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
height="100%"
|
||||
width="100%"
|
||||
fit="contain"
|
||||
withPlaceholder
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={height}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: 'rgba(30, 41, 59, 0.6)',
|
||||
}}
|
||||
>
|
||||
{typeIcon[item.item_type] || <LibraryIcon size={24} />}
|
||||
</Stack>
|
||||
)}
|
||||
{progress && progress.percentage ? (
|
||||
<RingProgress
|
||||
style={{ position: 'absolute', top: 10, right: 10 }}
|
||||
size={48}
|
||||
thickness={4}
|
||||
sections={[
|
||||
{
|
||||
value: Math.min(100, progress.percentage * 100),
|
||||
color: progress.completed ? 'green' : 'cyan',
|
||||
},
|
||||
]}
|
||||
label={
|
||||
<Text size="xs" c="white">
|
||||
{Math.round(progress.percentage * 100)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
<Stack spacing={4}>
|
||||
<Group justify="space-between" align="flex-start" gap="xs">
|
||||
<Text fw={600} size="sm" lineClamp={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.release_year && (
|
||||
<Badge variant="outline" size="xs">
|
||||
{item.release_year}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{showTypeBadge && (
|
||||
<Badge size="xs" color="violet" variant="light" tt="capitalize">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'watched' && (
|
||||
<Badge size="xs" color="green" variant="filled">
|
||||
Watched
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'in_progress' && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
In progress
|
||||
</Badge>
|
||||
)}
|
||||
{item.item_type === 'show' && watchSummary?.total_episodes ? (
|
||||
<Badge size="xs" color="blue" variant="outline">
|
||||
{watchSummary.completed_episodes || 0}/
|
||||
{watchSummary.total_episodes} episodes
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{Array.isArray(item.genres) && item.genres.length > 0 ? (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{item.genres.slice(0, 3).map((genre) => (
|
||||
<Badge key={`${item.id}-${genre}`} size="xs" color="blue" variant="light">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{formatRuntime(item.runtime_ms) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatRuntime(item.runtime_ms)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MediaCard);
|
||||
109
frontend/src/components/library/MediaCarousel.jsx
Normal file
109
frontend/src/components/library/MediaCarousel.jsx
Normal file
|
|
@ -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 (
|
||||
<Stack gap={4}>
|
||||
<Text fw={600} size="lg">{title}</Text>
|
||||
<Text size="sm" c="dimmed">{emptyMessage}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const scrollByCards = (dir) => {
|
||||
const vp = viewportRef.current;
|
||||
if (!vp) return;
|
||||
vp.scrollBy({
|
||||
left: dir * (cardWidth + snapGap) * SCROLL_STEP,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">{title}</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" aria-label="Scroll left" onClick={() => scrollByCards(-1)}>
|
||||
<ChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" aria-label="Scroll right" onClick={() => scrollByCards(1)}>
|
||||
<ChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea
|
||||
type="auto"
|
||||
scrollbarSize={8}
|
||||
offsetScrollbars
|
||||
viewportRef={viewportRef}
|
||||
styles={{
|
||||
viewport: {
|
||||
paddingBottom: rem(bottomPad), // <- keep bottoms visible
|
||||
scrollSnapType: 'x proximity',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
// add a touch of padding to the row too so shadows never collide
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
gap: rem(snapGap),
|
||||
alignItems: 'stretch',
|
||||
minWidth: 'max-content',
|
||||
paddingBottom: rem(2),
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
style={{
|
||||
flex: `0 0 ${rem(cardWidth)}`,
|
||||
width: rem(cardWidth),
|
||||
scrollSnapAlign: 'start',
|
||||
}}
|
||||
>
|
||||
<MediaCard
|
||||
item={item}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
size={cardSize}
|
||||
showTypeBadge={false}
|
||||
// ensure the card fills the wrapper (prevents internal overflow)
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCarousel;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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: <Film size={18} />,
|
||||
episode: <Tv2 size={18} />,
|
||||
show: <Library size={18} />,
|
||||
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 (
|
||||
<Group justify="center" py="xl">
|
||||
|
|
@ -31,94 +37,55 @@ const MediaGrid = ({ items, loading, onSelect }) => {
|
|||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No media found. Adjust filters or trigger a scan.
|
||||
No media found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4, lg: 5 }} spacing="lg">
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
shadow="sm"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<Stack spacing="xs">
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{item.poster_url ? (
|
||||
<Image
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
height={200}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={200}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: 'rgba(30, 41, 59, 0.6)',
|
||||
}}
|
||||
>
|
||||
{typeIcon[item.item_type] || <Library size={24} />}
|
||||
</Stack>
|
||||
)}
|
||||
{item.watch_progress && item.watch_progress.percentage ? (
|
||||
<RingProgress
|
||||
style={{ position: 'absolute', top: 10, right: 10 }}
|
||||
size={48}
|
||||
thickness={4}
|
||||
sections={[{
|
||||
value: Math.min(100, item.watch_progress.percentage * 100),
|
||||
color: item.watch_progress.completed ? 'green' : 'cyan',
|
||||
}]}
|
||||
label={
|
||||
<Text size="xs" c="white">
|
||||
{Math.round(item.watch_progress.percentage * 100)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
<Stack spacing={2}>
|
||||
<Group justify="space-between" align="start">
|
||||
<Text fw={600} size="sm" lineClamp={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.release_year && (
|
||||
<Badge variant="outline" size="xs">
|
||||
{item.release_year}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" color="violet" variant="light">
|
||||
{item.item_type}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={item.status === 'matched' ? 'green' : item.status === 'failed' ? 'red' : 'yellow'}
|
||||
variant="outline"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
{item.runtime_ms && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{(item.runtime_ms / 60000).toFixed(0)} min
|
||||
</Text>
|
||||
)}
|
||||
if (groupByLetter) {
|
||||
const grouped = groupItemsByLetter(items);
|
||||
const sortedKeys = Array.from(grouped.keys()).sort();
|
||||
return (
|
||||
<Stack spacing="xl">
|
||||
{sortedKeys.map((letter) => {
|
||||
const refCallback = (el) => {
|
||||
if (letterRefs && el) {
|
||||
letterRefs.current[letter] = el;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Stack key={letter} spacing="md" ref={refCallback}>
|
||||
<Text fw={700} size="lg">
|
||||
{letter}
|
||||
</Text>
|
||||
<SimpleGrid cols={columns} spacing="lg">
|
||||
{grouped.get(letter).map((item) => (
|
||||
<MediaCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
size={cardSize}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="lg">
|
||||
{items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
size={cardSize}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Stack spacing="xl">
|
||||
<MediaCarousel
|
||||
title="Continue Watching"
|
||||
items={continueWatching}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
emptyMessage="Start watching to see items here."
|
||||
/>
|
||||
<MediaCarousel
|
||||
title="Recently Released"
|
||||
items={recentlyReleased}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
<MediaCarousel
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const libraryView = (() => {
|
||||
if (sortOption === 'alpha') {
|
||||
letterRefs.current = {};
|
||||
}
|
||||
return (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Stack spacing="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Select
|
||||
label="Sort by"
|
||||
data={SORT_OPTIONS}
|
||||
value={sortOption}
|
||||
onChange={(value) => setSortOption(value || 'default')}
|
||||
w={220}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={() => fetchItems(selectedLibraryId)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
{sortOption === 'default' ? (
|
||||
recommendedView
|
||||
) : sortOption === 'genre' ? (
|
||||
<Stack spacing="xl">
|
||||
{genreCarousels.map(({ genre, items: genreItems }) => (
|
||||
<MediaCarousel
|
||||
key={genre}
|
||||
title={genre}
|
||||
items={genreItems}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{sortOption === 'alpha' && availableLetters.size > 0 && (
|
||||
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
|
||||
)}
|
||||
<MediaGrid
|
||||
items={sortedLibraryItems}
|
||||
loading={itemsLoading}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
groupByLetter={sortOption === 'alpha'}
|
||||
letterRefs={letterRefs}
|
||||
cardSize="md"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})();
|
||||
|
||||
const categoriesView = (
|
||||
<Stack spacing="xl">
|
||||
{genreCarousels.length === 0 ? (
|
||||
<Text c="dimmed">No categories available.</Text>
|
||||
) : (
|
||||
genreCarousels.map(({ genre, items: genreItems }) => (
|
||||
<MediaCarousel
|
||||
key={genre}
|
||||
title={genre}
|
||||
items={genreItems}
|
||||
onSelect={handleOpenItem}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const contextItem = contextMenu?.item;
|
||||
const contextStatus = contextItem?.watch_summary?.status;
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Box p="lg">
|
||||
<Stack spacing="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Title order={2}>Media Libraries</Title>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack spacing={4}>
|
||||
<Title order={2}>{isMovies ? 'Movies' : 'TV Shows'}</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Create libraries, trigger scans, and manage your offline media collection.
|
||||
{selectedLibrary ? selectedLibrary.name : 'Select a library to begin.'}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
</Stack>
|
||||
<Group align="center" gap="sm">
|
||||
<Select
|
||||
placeholder={librariesLoading ? 'Loading libraries...' : 'Choose library'}
|
||||
data={libraries.map((library) => ({
|
||||
value: String(library.id),
|
||||
label: library.name,
|
||||
}))}
|
||||
value={selectedLibraryId ? String(selectedLibraryId) : null}
|
||||
onChange={(value) => setSelectedLibrary(Number(value))}
|
||||
w={220}
|
||||
disabled={librariesLoading || libraries.length === 0}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingLibrary(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onClick={() => setFormOpen(true)}
|
||||
>
|
||||
New Library
|
||||
Add Library
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => fetchLibraries()}
|
||||
title="Refresh libraries"
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => setScanDrawerOpen(true)}
|
||||
color="blue"
|
||||
onClick={handleScanLibrary}
|
||||
title="View recent scans"
|
||||
>
|
||||
<ListChecks size={18} />
|
||||
|
|
@ -220,148 +550,58 @@ const LibraryPage = () => {
|
|||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group align="flex-end" grow>
|
||||
<TextInput
|
||||
label="Search"
|
||||
placeholder="Search libraries"
|
||||
leftSection={<Filter size={16} />}
|
||||
value={librarySearch}
|
||||
onChange={(event) => setLibrarySearch(event.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<SegmentedControl
|
||||
value={libraryFilters.type}
|
||||
onChange={(value) => {
|
||||
setLibraryFilters({ type: value });
|
||||
fetchLibraries();
|
||||
}}
|
||||
data={[
|
||||
{ label: 'All types', value: 'all' },
|
||||
{ label: 'Movies', value: 'movies' },
|
||||
{ label: 'Shows', value: 'shows' },
|
||||
{ label: 'Mixed', value: 'mixed' },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={libraryFilters.autoScan}
|
||||
onChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
<Group align="center" gap="sm">
|
||||
<TextInput
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder="Search library"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{librariesLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : libraries.length === 0 ? (
|
||||
<Stack align="center" spacing="md" py="xl">
|
||||
<ServerOff size={48} />
|
||||
<Text c="dimmed">No libraries yet. Create one to get started.</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setFormOpen(true)}>
|
||||
Create your first library
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
|
||||
{libraries.map((library) => (
|
||||
<LibraryCard
|
||||
key={library.id}
|
||||
library={library}
|
||||
selected={library.id === selectedLibraryId}
|
||||
onSelect={(id) => {
|
||||
setSelectedLibrary(id);
|
||||
setPage(1);
|
||||
}}
|
||||
onEdit={(lib) => {
|
||||
setEditingLibrary(lib);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
onScan={handleScan}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{selectedLibrary ? (
|
||||
<Stack spacing="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Title order={3}>{selectedLibrary.name}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Browsing items in this library.
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search media"
|
||||
leftSection={<Filter size={16} />}
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
maw={260}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={itemFilters.type}
|
||||
onChange={(value) => {
|
||||
setItemFilters({ type: value });
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Movies', value: 'movie' },
|
||||
{ label: 'Shows', value: 'show' },
|
||||
{ label: 'Episodes', value: 'episode' },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={itemFilters.status}
|
||||
onChange={(value) => {
|
||||
setItemFilters({ status: value });
|
||||
setPage(1);
|
||||
}}
|
||||
data={statusOptions}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<MediaGrid items={items} loading={itemsLoading} onSelect={onMediaSelect} />
|
||||
|
||||
{total > pageSize ? (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {(page - 1) * pageSize + 1}–
|
||||
{Math.min(page * pageSize, total)} of {total}
|
||||
</Text>
|
||||
<Pagination
|
||||
total={Math.ceil(total / pageSize)}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
<div>
|
||||
{activeTab === 'recommended' && recommendedView}
|
||||
{activeTab === 'library' && libraryView}
|
||||
{activeTab === 'categories' && categoriesView}
|
||||
</div>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center">
|
||||
Select a library to explore its content.
|
||||
</Text>
|
||||
<Stack align="center" py="xl" spacing="md">
|
||||
<Text c="dimmed">Select or create a media library to get started.</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<LibraryFormModal
|
||||
opened={formOpen}
|
||||
onClose={() => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<LibraryScanDrawer
|
||||
|
|
@ -377,6 +617,85 @@ const LibraryPage = () => {
|
|||
closeItem();
|
||||
}}
|
||||
/>
|
||||
|
||||
{contextMenu && contextItem && contextMenuPosition && (
|
||||
<Portal>
|
||||
<Paper
|
||||
shadow="md"
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: contextMenuPosition.top,
|
||||
left: contextMenuPosition.left,
|
||||
zIndex: 1000,
|
||||
minWidth: 220,
|
||||
background: 'rgba(18, 21, 35, 0.97)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<Play size={16} />}
|
||||
onClick={() => {
|
||||
handleOpenItem(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
{contextStatus === 'in_progress' ? 'Continue Watching' : 'Play'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<RefreshCcw size={16} />}
|
||||
onClick={async () => {
|
||||
await API.refreshMediaItemMetadata(contextItem.id);
|
||||
notifications.show({
|
||||
title: 'Metadata queued',
|
||||
message: 'Metadata refresh has been requested.',
|
||||
color: 'blue',
|
||||
});
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Refresh Metadata
|
||||
</Button>
|
||||
<Divider my="xs" />
|
||||
{contextStatus === 'watched' ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
await handleMarkUnwatched(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Mark unwatched
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
await handleMarkWatched(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Mark watched
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<Trash2 size={16} />}
|
||||
onClick={async () => {
|
||||
await handleDeleteItem(contextItem);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue