diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index 6dcd2341..a2ce581e 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -266,6 +266,8 @@ class MediaItemViewSet(viewsets.ModelViewSet): def get_queryset(self): user = getattr(self.request, "user", None) + include_missing_param = (self.request.query_params.get("include_missing") or "").lower() + self._include_missing = include_missing_param in {"1", "true", "yes"} if user and user.is_authenticated: watch_prefetch = Prefetch( "watch_progress", @@ -304,6 +306,8 @@ class MediaItemViewSet(viewsets.ModelViewSet): .prefetch_related(episode_watch_prefetch) .order_by("season_number", "episode_number", "id") ) + if not self._include_missing: + children_qs = children_qs.filter(is_missing=False) return base_queryset.prefetch_related( watch_prefetch, Prefetch( @@ -317,6 +321,11 @@ class MediaItemViewSet(viewsets.ModelViewSet): def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) + include_missing = getattr(self, "_include_missing", False) + if not include_missing: + queryset = queryset.exclude( + Q(item_type=models.MediaItem.TYPE_EPISODE) & Q(is_missing=True) + ) search = self.request.query_params.get("search") if search: search = search.strip() diff --git a/apps/media_library/migrations/0002_mediaitem_is_missing.py b/apps/media_library/migrations/0002_mediaitem_is_missing.py new file mode 100644 index 00000000..31eaf431 --- /dev/null +++ b/apps/media_library/migrations/0002_mediaitem_is_missing.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("media_library", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="mediaitem", + name="is_missing", + field=models.BooleanField(default=False), + ), + ] + diff --git a/apps/media_library/models.py b/apps/media_library/models.py index 2c0528bb..2e8f7fde 100644 --- a/apps/media_library/models.py +++ b/apps/media_library/models.py @@ -456,6 +456,7 @@ class MediaItem(models.Model): ) item_type = models.CharField(max_length=16, choices=ITEM_TYPE_CHOICES, default=TYPE_OTHER) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING) + is_missing = models.BooleanField(default=False) title = models.CharField(max_length=512) sort_title = models.CharField(max_length=512, blank=True) diff --git a/apps/media_library/naming.py b/apps/media_library/naming.py new file mode 100644 index 00000000..21dcf096 --- /dev/null +++ b/apps/media_library/naming.py @@ -0,0 +1,140 @@ +import os +import re +from dataclasses import dataclass +from typing import Optional + +from guessit import guessit + +from apps.media_library.models import Library, MediaItem +from apps.media_library.utils import ClassificationResult, _json_safe, normalize_title + +SEASON_FOLDER_PATTERN = re.compile(r"^(?:season|series|s)[\s\._-]*([0-9]{1,3})$", re.IGNORECASE) +EPISODE_PATTERN = re.compile( + r""" + (?: + s(?P\d{1,3}) + [\.\-_\s]* + e(?P\d{1,4}) + (?:[\.\-_\s]*e?(?P\d{1,4}))? + ) + | + (?: + (?P\d{1,4}) + ) + """, + re.IGNORECASE | re.VERBOSE, +) + + +def _strip_extension(file_name: str) -> str: + base, _ext = os.path.splitext(file_name) + return base + + +def _ensure_series_name_from_path(relative_path: str, default: str | None = None) -> str: + """ + Best effort extraction of a series title from a relative path. + Matches Jellyfin's approach of sanitizing folder names by replacing + dots/underscores with spaces when they separate words. + """ + if not relative_path: + return default or "" + + segments = [segment for segment in relative_path.split(os.sep) if segment] + if not segments: + return default or "" + + series_candidate = segments[0] + sanitized = re.sub(r"(([^._]{2,})[\._]*)|([\._]([^._]{2,}))", r"\2\4", series_candidate).replace("_", " ").replace(".", " ") + sanitized = re.sub(r"\s+", " ", sanitized).strip() + return sanitized or (default or "") + + +def _season_from_segments(segments: list[str]) -> Optional[int]: + for segment in reversed(segments): + match = SEASON_FOLDER_PATTERN.match(segment) + if match: + try: + return int(match.group(1)) + except (TypeError, ValueError): + continue + return None + + +def classify_media_entry( + library: Library, + *, + relative_path: str, + file_name: str, +) -> ClassificationResult: + """ + Wrapper around guessit that injects folder hints similar to Jellyfin's resolver. + """ + base_name = _strip_extension(file_name) + guess_data = {} + + # Provide path context to guessit. + try: + guess_data = guessit(file_name) + except Exception: + guess_data = {} + + segments = [segment for segment in relative_path.split(os.sep) if segment] + series_name = _ensure_series_name_from_path(relative_path, default=guess_data.get("title") or base_name) + + if library.library_type == Library.LIBRARY_TYPE_SHOWS: + season_number = guess_data.get("season") + episode_number = guess_data.get("episode") + + if season_number is None: + season_number = _season_from_segments(segments) + + if episode_number is None: + episode_number = guess_data.get("episode_list", [None])[0] if guess_data.get("episode_list") else None + + if episode_number is None: + match = EPISODE_PATTERN.search(file_name) + if match: + episode_number = match.group("episode") + if episode_number: + try: + episode_number = int(episode_number) + except (TypeError, ValueError): + episode_number = None + if season_number is None and match.group("season"): + try: + season_number = int(match.group("season")) + except (TypeError, ValueError): + season_number = None + + detected_type = MediaItem.TYPE_EPISODE if season_number is not None and episode_number is not None else MediaItem.TYPE_SHOW + normalized_title = normalize_title(series_name or base_name) + data = _json_safe(guess_data) + if season_number is not None: + data["season"] = season_number + if episode_number is not None: + data["episode"] = episode_number + if series_name: + data["series"] = series_name + + return ClassificationResult( + detected_type=detected_type, + title=series_name or base_name, + year=guess_data.get("year"), + season=season_number, + episode=episode_number, + episode_title=guess_data.get("episode_title"), + data=data, + ) + + # Movies & other types fall back to original logic. + detected_type = MediaItem.TYPE_MOVIE if library.library_type == Library.LIBRARY_TYPE_MOVIES else MediaItem.TYPE_OTHER + data = _json_safe(guess_data) + title = guess_data.get("title") or base_name + return ClassificationResult( + detected_type=detected_type, + title=title, + year=guess_data.get("year"), + data=data, + ) + diff --git a/apps/media_library/serializers.py b/apps/media_library/serializers.py index c04c77cc..df4d82d8 100644 --- a/apps/media_library/serializers.py +++ b/apps/media_library/serializers.py @@ -400,6 +400,7 @@ class MediaItemListSerializer(MediaItemBaseSerializer): "season_number", "episode_number", "genres", + "is_missing", "tags", "tagline", "metadata_last_synced_at", @@ -448,6 +449,7 @@ class MediaItemSerializer(MediaItemBaseSerializer): "metadata", "metadata_last_synced_at", "metadata_source", + "is_missing", "first_imported_at", "updated_at", "files", @@ -470,6 +472,7 @@ class MediaItemSerializer(MediaItemBaseSerializer): "vod_series", "vod_episode", "watch_progress", + "is_missing", ] extra_kwargs = { "genres": {"required": False, "allow_null": True}, diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py index 771ac9f2..658b5113 100644 --- a/apps/media_library/tasks.py +++ b/apps/media_library/tasks.py @@ -1,4 +1,5 @@ import logging +import os from datetime import timedelta from typing import Optional, Set from collections import deque @@ -9,19 +10,14 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.conf import settings from django.db import transaction -from django.db.models import Q +from django.db.models import Q, Count 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, - classify_media_file, - probe_media_file, - resolve_media_item, -) +from apps.media_library.utils import LibraryScanner, apply_probe_metadata, probe_media_file, resolve_media_item +from apps.media_library.naming import classify_media_entry from apps.media_library.transcode import ensure_browser_ready_source from apps.media_library.vod_sync import ( sync_library_to_vod, @@ -480,6 +476,7 @@ def scan_library_task( matched = 0 unmatched = 0 media_item_ids: Set[int] = set() + series_ids: Set[int] = set() metadata_queue_ids: Set[int] = set() metadata_accounted_ids: Set[int] = set() pending_probe_ids: set[int] = set() @@ -732,6 +729,10 @@ def scan_library_task( except MediaItem.DoesNotExist: continue else: + if media_obj.item_type == MediaItem.TYPE_SHOW: + series_ids.add(media_obj.id) + elif media_obj.parent_id: + series_ids.add(media_obj.parent_id) if is_new_media: _send_media_item_update(media_obj, status="progress") needs_metadata = ( @@ -770,6 +771,39 @@ def scan_library_task( scanner.mark_missing_files() + def reconcile_missing(series_id_set: Set[int]) -> None: + if not series_id_set: + return + + episode_qs = ( + MediaItem.objects.filter( + parent_id__in=series_id_set, + item_type=MediaItem.TYPE_EPISODE, + ) + .annotate(file_count=Count("files")) + ) + + missing_ids = list( + episode_qs.filter(file_count=0, is_missing=False).values_list("id", flat=True) + ) + if missing_ids: + MediaItem.objects.filter(pk__in=missing_ids).update( + is_missing=True, + status=MediaItem.STATUS_FAILED, + updated_at=timezone.now(), + ) + + restored_ids = list( + episode_qs.filter(file_count__gt=0, is_missing=True).values_list("id", flat=True) + ) + if restored_ids: + MediaItem.objects.filter(pk__in=restored_ids).update( + is_missing=False, + updated_at=timezone.now(), + ) + + reconcile_missing(series_ids) + if media_item_ids: metadata_qs = ( MediaItem.objects.filter(pk__in=media_item_ids) @@ -920,17 +954,34 @@ def _identify_media_file( except MediaFile.DoesNotExist: 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 + relative_dir = os.path.dirname(file_record.relative_path or "") if file_record.relative_path else "" + classification = classify_media_entry( + library, + relative_path=relative_dir, + file_name=file_record.file_name, + ) + target_item = None if target_item_id: target_item = MediaItem.objects.filter(pk=target_item_id, library=library).first() + if ( + library.library_type == Library.LIBRARY_TYPE_SHOWS + and classification.detected_type != MediaItem.TYPE_EPISODE + and not target_item + ): + # Unable to positively identify this episode; treat as unmatched. + if file_record.media_item_id is not None: + file_record.media_item = None + file_record.save(update_fields=["media_item", "updated_at"]) + return { + "file_id": file_id, + "media_item_id": None, + "parent_media_item_id": None, + "matched": 0, + "unmatched": 1, + } + media_item = resolve_media_item(library, classification, target_item=target_item) matched = 0 unmatched = 0 diff --git a/apps/media_library/utils.py b/apps/media_library/utils.py index fb6ef13e..84ff2318 100644 --- a/apps/media_library/utils.py +++ b/apps/media_library/utils.py @@ -282,51 +282,6 @@ class LibraryScanner: self.log_messages.append(message) -def classify_media_file(file_name: str) -> ClassificationResult: - base_name = Path(file_name).stem - try: - data = guessit(file_name) - except Exception as exc: # noqa: BLE001 - logger.debug("guessit failed for %s: %s", file_name, exc) - return ClassificationResult( - detected_type=MediaItem.TYPE_OTHER, - title=base_name, - data={"error": str(exc)}, - ) - - data = _json_safe(data) - - guess_type = data.get("type") - detected_type = MediaItem.TYPE_OTHER - - if guess_type == "movie": - detected_type = MediaItem.TYPE_MOVIE - elif guess_type == "episode": - detected_type = MediaItem.TYPE_EPISODE - elif guess_type in {"show", "series", "tv"}: - detected_type = MediaItem.TYPE_SHOW - elif guess_type == "season": - detected_type = MediaItem.TYPE_SEASON - - title = data.get("title") or base_name - - classification = ClassificationResult( - detected_type=detected_type, - title=title, - year=_first_numeric(data.get("year")), - season=_first_numeric(data.get("season")), - episode=_first_numeric(data.get("episode")), - episode_title=data.get("episode_title"), - data=data, - ) - - if detected_type == MediaItem.TYPE_EPISODE: - classification.data = dict(data) - classification.data["series_title"] = data.get("series") or data.get("title") or base_name - - return classification - - def resolve_media_item( library: Library, classification: ClassificationResult, @@ -420,6 +375,9 @@ def resolve_media_item( ).first() ) if episode_item: + if episode_item.is_missing: + episode_item.is_missing = False + episode_item.save(update_fields=["is_missing", "updated_at"]) if classification.episode_title and not episode_item.title: episode_item.title = classification.episode_title episode_item.save(update_fields=["title", "updated_at"])