mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
Better Scanning
Fix problem where TV series was scanning data that wasn't really there.
This commit is contained in:
parent
92c50aa119
commit
380e36635e
7 changed files with 238 additions and 60 deletions
|
|
@ -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()
|
||||
|
|
|
|||
16
apps/media_library/migrations/0002_mediaitem_is_missing.py
Normal file
16
apps/media_library/migrations/0002_mediaitem_is_missing.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
140
apps/media_library/naming.py
Normal file
140
apps/media_library/naming.py
Normal 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,
|
||||
)
|
||||
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue