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:
Binary file not shown.
@@ -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
|
||||||
@@ -184,6 +185,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"""
|
||||||
|
|||||||
+155
-5
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
+23
-13
@@ -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
|
||||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
_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)
|
||||||
|
_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)
|
||||||
|
|||||||
Reference in New Issue
Block a user