Better Scanning

Fix problem where TV series was scanning data that wasn't really there.
This commit is contained in:
Dispatcharr 2025-10-12 08:29:21 -05:00
parent 92c50aa119
commit 380e36635e
7 changed files with 238 additions and 60 deletions

View file

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

View file

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

View file

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

View file

@ -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<season>\d{1,3})
[\.\-_\s]*
e(?P<episode>\d{1,4})
(?:[\.\-_\s]*e?(?P<episode_end>\d{1,4}))?
)
|
(?:
(?P<abs_episode>\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,
)

View file

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

View file

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

View file

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