feat: enhance NFO processing and movie detection

* fix: NFO processing overhaul - looking GOOD!

  Listen up, buddy! Your boy Cat just made this NFO processing
  smoother than my pompadour on a good hair day!

  WHAT'S THE DEAL?
  • Movie NFOs were getting DISSED when dates went missing
  • Episode NFOs with long names were cluttering up the joint
  • Nobody wants a messy media library - that's just WRONG!

  WHAT I FIXED (because I'm THAT good):
  • Movies: NFO processing now happens FIRST, baby! No more
    skipping when dates are wonky - we standardize EVERYTHING!
  • TV Shows: Long episode NFO names? GONE! We migrate that
    metadata to proper S##E## format and trash the old junk
  • Dates get moved to the bottom where they belong (it's all
    about the STYLE!)
  • NFOGuard signatures added to everything (gotta sign your work!)

  FILES TOUCHED:
  • nfoguard.py:1008-1025 - Fixed that early exit nonsense
  • core/nfo_manager.py - Added find_existing_episode_nfo() function
  • core/nfo_manager.py - Enhanced create_episode_nfo() with migration

  RESULTS:
  ✨ Existing movie.nfo files get the full treatment
  ✨ Long episode NFO names become sleek S##E## format
  ✨ All metadata preserved (we're not ANIMALS!)
  ✨ Dates organized properly at the bottom
  ✨ NFOGuard branding on everything

  This code is now looking SO good, it should be in GQ!
  What's not to like about perfection?

* testing: Testing database date adds for old files

* database debuging

* fixing database calls as well as ID for imdb

* fix: dirs with no imdb in title

movies with no imdb getting skipped

* update: fix core parsing
This commit is contained in:
sbcrumb authored and GitHub committed 2025-09-20 20:24:02 -04:00
1 parent d5937b13be
commit cd22cd3340
4 files changed
+183 -17

No files matched your search

Binary file not shown.
+6
View File
@@ -173,6 +173,7 @@ class NFOGuardDatabase:
def upsert_movie_dates(self, imdb_id: str, released: Optional[str], def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
dateadded: Optional[str], source: str, has_video_file: bool = False): dateadded: Optional[str], source: str, has_video_file: bool = False):
"""Insert or update movie date record""" """Insert or update movie date record"""
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
with self.get_connection() as conn: with self.get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Use INSERT OR REPLACE to ensure we always update the dates properly # Use INSERT OR REPLACE to ensure we always update the dates properly
@@ -185,6 +186,11 @@ class NFOGuardDatabase:
) )
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat())) """, (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
# Debug: Check what was actually saved
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,))
result = cursor.fetchone()
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}")
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
"""Get all episodes for a series""" """Get all episodes for a series"""
with self.get_connection() as conn: with self.get_connection() as conn:
+155 -5
View File
@@ -49,6 +49,93 @@ class NFOManager:
return None return None
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""Extract IMDb ID from NFO file content"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
if imdb_uniqueid is not None and imdb_uniqueid.text:
imdb_id = imdb_uniqueid.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Check for legacy <imdbid>ttXXXXXX</imdbid>
imdbid_elem = root.find('.//imdbid')
if imdbid_elem is not None and imdbid_elem.text:
imdb_id = imdbid_elem.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Check for legacy <imdb>ttXXXXXX</imdb>
imdb_elem = root.find('.//imdb')
if imdb_elem is not None and imdb_elem.text:
imdb_id = imdb_elem.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
pass
return None
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""Find IMDb ID from directory name, filenames, or NFO file"""
# First try directory name
imdb_id = self.parse_imdb_from_path(movie_dir)
if imdb_id:
return imdb_id
# Try all files in the directory for IMDb ID patterns
for file_path in movie_dir.iterdir():
if file_path.is_file():
imdb_id = self.parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
# Finally, try NFO file content
nfo_path = movie_dir / "movie.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
print(f"🔍 Found IMDb ID in NFO file: {imdb_id} from {nfo_path}")
return imdb_id
return None
def _parse_nfo_with_tolerance(self, nfo_path: Path):
"""Parse NFO file with tolerance for URLs appended after XML"""
try:
# First try normal parsing
tree = ET.parse(nfo_path)
return tree.getroot()
except ET.ParseError as e:
# If parsing fails, try to extract just the XML part
try:
with open(nfo_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the last </movie> tag and truncate after it
last_movie_end = content.rfind('</movie>')
if last_movie_end != -1:
xml_content = content[:last_movie_end + 8] # +8 for </movie>
# Try parsing the truncated content
root = ET.fromstring(xml_content)
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
return root
else:
# Re-raise original error if we can't find </movie>
raise e
except Exception:
# Re-raise original error if our fix attempt fails
raise e
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str, def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
released: Optional[str] = None, source: str = "unknown", released: Optional[str] = None, source: str = "unknown",
lock_metadata: bool = True) -> None: lock_metadata: bool = True) -> None:
@@ -59,8 +146,8 @@ class NFOManager:
# Try to load existing NFO file # Try to load existing NFO file
if nfo_path.exists(): if nfo_path.exists():
try: try:
tree = ET.parse(nfo_path) # Try to parse the XML, handling URLs appended after </movie>
movie = tree.getroot() movie = self._parse_nfo_with_tolerance(nfo_path)
# Ensure root element is <movie> # Ensure root element is <movie>
if movie.tag != "movie": if movie.tag != "movie":
@@ -279,6 +366,48 @@ class NFOManager:
except Exception as e: except Exception as e:
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}") print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find existing episode NFO file that matches season/episode but isn't standardized name"""
if not season_dir.exists():
return None
# Standard filename pattern we're looking to create
standard_pattern = f"S{season_num:02d}E{episode_num:02d}.nfo"
# Look for NFO files in the season directory
for nfo_file in season_dir.glob("*.nfo"):
# Skip if it's already the standard format
if nfo_file.name == standard_pattern:
continue
# Check if this NFO contains the right season/episode
try:
tree = ET.parse(nfo_file)
root = tree.getroot()
if root.tag == "episodedetails":
# Check for season/episode elements
season_elem = root.find("season")
episode_elem = root.find("episode")
if (season_elem is not None and episode_elem is not None and
season_elem.text and episode_elem.text):
try:
file_season = int(season_elem.text)
file_episode = int(episode_elem.text)
if file_season == season_num and file_episode == episode_num:
print(f"🔍 Found existing episode NFO: {nfo_file.name} -> will migrate to {standard_pattern}")
return nfo_file
except ValueError:
continue
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
continue
return None
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int, def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str, aired: Optional[str], dateadded: Optional[str], source: str,
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None: lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
@@ -287,17 +416,30 @@ class NFOManager:
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
nfo_path = season_dir / episode_filename nfo_path = season_dir / episode_filename
# Track if we need to delete an old long-named NFO file
old_nfo_to_delete = None
try: try:
# Try to load existing NFO file # First, check for existing long-named NFO files that need migration
if nfo_path.exists(): existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
# Try to load existing NFO file (either standard or long-named)
source_nfo_path = nfo_path if nfo_path.exists() else existing_long_nfo
if source_nfo_path:
try: try:
tree = ET.parse(nfo_path) tree = ET.parse(source_nfo_path)
episode = tree.getroot() episode = tree.getroot()
# Ensure root element is <episodedetails> # Ensure root element is <episodedetails>
if episode.tag != "episodedetails": if episode.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>") raise ValueError("Root element is not <episodedetails>")
# If we're migrating from a long-named file, mark it for deletion
if existing_long_nfo and source_nfo_path == existing_long_nfo:
old_nfo_to_delete = existing_long_nfo
print(f"📦 Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}")
# Remove existing NFOGuard-managed elements to avoid duplicates # Remove existing NFOGuard-managed elements to avoid duplicates
# These will be re-added at the bottom # These will be re-added at the bottom
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"] nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
@@ -374,6 +516,14 @@ class NFOManager:
print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
# Clean up old long-named NFO file if we migrated from it
if old_nfo_to_delete and old_nfo_to_delete.exists():
try:
old_nfo_to_delete.unlink()
print(f"🗑️ Cleaned up old NFO file: {old_nfo_to_delete.name}")
except Exception as cleanup_error:
print(f"⚠️ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}")
except Exception as e: except Exception as e:
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}") print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
+22 -12
View File
@@ -949,9 +949,9 @@ class MovieProcessor:
def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None: def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None:
"""Process a movie directory""" """Process a movie directory"""
imdb_id = self.nfo_manager.parse_imdb_from_path(movie_path) imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id: if not imdb_id:
_log("ERROR", f"No IMDb ID found in movie path: {movie_path}") _log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
return return
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})") _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
@@ -1005,24 +1005,34 @@ class MovieProcessor:
# Use existing movie date decision logic # Use existing movie date decision logic
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
# Skip processing if no valid date found and file dates disabled # Create NFO regardless of date availability (preserves existing metadata)
if dateadded is None:
_log("WARNING", f"Skipping movie {movie_path.name} - no valid date source available")
self.db.upsert_movie_dates(imdb_id, released, None, source, True)
return
# Create NFO
if config.manage_nfo: if config.manage_nfo:
self.nfo_manager.create_movie_nfo( self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata movie_path, imdb_id, dateadded, released, source, config.lock_metadata
) )
# Update file mtimes # Skip remaining processing if no valid date found and file dates disabled
if dateadded is None:
_log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed")
self.db.upsert_movie_dates(imdb_id, released, None, source, True)
return
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
# Update file mtimes (only if we have a valid date)
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED": if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
# Save to database # Save to database
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
try:
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True) self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("DEBUG", f"Database save completed for {imdb_id}")
except Exception as e:
_log("ERROR", f"Database save failed for {imdb_id}: {e}")
raise
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
@@ -1874,7 +1884,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
_log("INFO", f"Scanning movies in: {scan_path}") _log("INFO", f"Scanning movies in: {scan_path}")
movie_count = 0 movie_count = 0
for item in scan_path.iterdir(): for item in scan_path.iterdir():
if item.is_dir() and nfo_manager.parse_imdb_from_path(item): if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
movie_count += 1 movie_count += 1
_log("INFO", f"Processing movie: {item.name}") _log("INFO", f"Processing movie: {item.name}")
try: try:
@@ -1986,7 +1996,7 @@ async def test_movie_scan():
if path.exists(): if path.exists():
for item in path.iterdir(): for item in path.iterdir():
if item.is_dir() and nfo_manager.parse_imdb_from_path(item): if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
path_result["movies_found"] += 1 path_result["movies_found"] += 1
results.append(path_result) results.append(path_result)