Just ignore this

This commit is contained in:
Dispatcharr 2025-09-17 20:06:32 -05:00
parent 5899f11b94
commit c16ab49d56
23 changed files with 2948 additions and 713 deletions

View file

@ -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()

View file

@ -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

View file

@ -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),
),
]

View file

@ -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)."""

View file

@ -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),

View file

@ -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")

View file

@ -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,
)

View file

@ -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

View file

@ -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 />} />

View file

@ -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({

View file

@ -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(

View file

@ -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 */}

View file

@ -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

View 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>
</>
);
}

View file

@ -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

View file

@ -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>

View 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);

View 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

View file

@ -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>
);

View file

@ -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>
);
};

View file

@ -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;
};

View file

@ -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);