feat: Preserve TV episode NFO filenames matching video files
Local Docker Build (Dev) / build-dev (push) Successful in 29s

Instead of migrating long NFO names to standardized S01E01.nfo format,
now preserves original filenames that match video files while still
populating NFOGuard metadata (dateadded, aired, season, episode).

Changes:
- Added update_episode_nfo_preserving_name() method to NFOManager
- Added find_episode_nfo_matching_video() to locate NFO files by video name pattern
- Modified TVProcessor to update existing NFOs in-place rather than migrate names
- Episodes with existing NFOs now get "nfo_update_required" source for special handling

This maintains the user's preferred naming convention where NFO files
match their corresponding video files exactly.
This commit is contained in:
2025-09-26 08:16:42 -04:00
parent 24d94306c1
commit 4980d1ae04
3 changed files with 684 additions and 12 deletions
+482 -1
View File
@@ -355,4 +355,485 @@ def register_routes(
}
except Exception as e:
_log("ERROR", f"Lockdata stripping failed: {e}")
raise HTTPException(status_code=500, detail=f"Lockdata stripping failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Lockdata stripping failed: {str(e)}")
@app.get("/debug/nfo/{path:path}")
async def debug_nfo_content(path: str):
"""Debug NFO file content to troubleshoot Emby title issues"""
try:
from pathlib import Path
import xml.etree.ElementTree as ET
# Construct full path
nfo_path = Path("/" + path.lstrip("/"))
if not nfo_path.exists():
raise HTTPException(status_code=404, detail=f"NFO file not found: {nfo_path}")
if not str(nfo_path).endswith('.nfo'):
raise HTTPException(status_code=400, detail="Path must point to a .nfo file")
# Parse NFO content
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Extract key elements that Emby uses
result = {
"file_path": str(nfo_path),
"root_element": root.tag,
"elements_found": {},
"nfoguard_data": {},
"raw_xml": None
}
# Check for key elements
key_elements = ["title", "plot", "premiered", "dateadded", "lockdata", "uniqueid", "year", "runtime"]
for element_name in key_elements:
elem = root.find(f".//{element_name}")
if elem is not None:
result["elements_found"][element_name] = {
"text": elem.text,
"attributes": elem.attrib if elem.attrib else None
}
# Check NFOGuard specific data
nfoguard_source = root.find(".//nfoguard_source")
if nfoguard_source is not None:
result["nfoguard_data"]["source"] = nfoguard_source.text
# Get first few lines of raw XML for inspection
with open(nfo_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
lines = raw_content.split('\n')
result["raw_xml"] = lines[:20] # First 20 lines
return result
except ET.ParseError as e:
# If XML parsing fails, return raw content
with open(nfo_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
return {
"file_path": str(nfo_path),
"parse_error": str(e),
"raw_content": raw_content[:1000] # First 1000 chars
}
except Exception as e:
_log("ERROR", f"NFO debug failed: {e}")
raise HTTPException(status_code=500, detail=f"NFO debug failed: {str(e)}")
@app.get("/debug/emby-title-test/{imdb_id}")
async def test_emby_title_format(imdb_id: str):
"""Test different NFO title formats for Emby compatibility"""
try:
# Create a test NFO with different title positioning to see what Emby prefers
test_formats = {
"title_first": """<?xml version="1.0" encoding="utf-8"?>
<movie>
<title>Test Movie Title</title>
<plot>Test plot description</plot>
<uniqueid type="imdb" default="true">{imdb_id}</uniqueid>
<dateadded>2023-01-01T12:00:00</dateadded>
</movie>""",
"title_after_plot": """<?xml version="1.0" encoding="utf-8"?>
<movie>
<plot>Test plot description</plot>
<title>Test Movie Title</title>
<uniqueid type="imdb" default="true">{imdb_id}</uniqueid>
<dateadded>2023-01-01T12:00:00</dateadded>
</movie>""",
"title_before_uniqueid": """<?xml version="1.0" encoding="utf-8"?>
<movie>
<plot>Test plot description</plot>
<uniqueid type="imdb" default="true">{imdb_id}</uniqueid>
<title>Test Movie Title</title>
<dateadded>2023-01-01T12:00:00</dateadded>
</movie>"""
}
for format_name, xml_content in test_formats.items():
test_formats[format_name] = xml_content.format(imdb_id=imdb_id)
return {
"imdb_id": imdb_id,
"test_formats": test_formats,
"recommendation": "Try creating a test NFO file with title as the first element after <movie>"
}
except Exception as e:
_log("ERROR", f"Emby title test failed: {e}")
raise HTTPException(status_code=500, detail=f"Emby title test failed: {str(e)}")
@app.get("/debug/tv-episode/{path:path}")
async def debug_tv_episode_nfo(path: str):
"""Debug TV episode NFO specifically for Emby title issues"""
try:
from pathlib import Path
import xml.etree.ElementTree as ET
# Handle paths like "media/TV/ShowName/Season 01/S01E01.nfo"
nfo_path = Path("/" + path.lstrip("/"))
if not nfo_path.exists():
raise HTTPException(status_code=404, detail=f"Episode NFO not found: {nfo_path}")
if not str(nfo_path).endswith('.nfo'):
raise HTTPException(status_code=400, detail="Path must point to a .nfo file")
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Check if it's an episode NFO
if root.tag != "episodedetails":
return {"error": f"Not a TV episode NFO (root is {root.tag}, expected episodedetails)"}
# Extract TV-specific elements
result = {
"file_path": str(nfo_path),
"nfo_type": "TV Episode",
"emby_compatibility_check": {},
"elements": {},
"video_file_info": None,
"recommendations": []
}
# Check critical elements for TV episodes
tv_elements = ["title", "season", "episode", "aired", "plot", "uniqueid"]
for elem_name in tv_elements:
elem = root.find(elem_name)
if elem is not None:
result["elements"][elem_name] = {
"text": elem.text,
"attributes": elem.attrib if elem.attrib else {}
}
# Emby compatibility checks
title_elem = root.find("title")
if title_elem is not None and title_elem.text:
result["emby_compatibility_check"]["has_title"] = True
result["emby_compatibility_check"]["title_value"] = title_elem.text.strip()
result["emby_compatibility_check"]["title_position"] = list(root).index(title_elem)
if list(root).index(title_elem) == 0:
result["emby_compatibility_check"]["title_is_first"] = True
else:
result["emby_compatibility_check"]["title_is_first"] = False
result["recommendations"].append("Consider moving <title> to be the first element after <episodedetails>")
else:
result["emby_compatibility_check"]["has_title"] = False
result["recommendations"].append("CRITICAL: No <title> element found!")
# Check for video file in same directory
season_dir = nfo_path.parent
episode_pattern = nfo_path.stem # S01E01 from S01E01.nfo
video_exts = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
for video_ext in video_exts:
potential_video = season_dir / f"{episode_pattern}{video_ext}"
if potential_video.exists():
result["video_file_info"] = {
"filename": potential_video.name,
"matches_nfo": True
}
break
# Look for any video file with the pattern
if not result["video_file_info"]:
for video_file in season_dir.glob(f"*{episode_pattern}*"):
if video_file.suffix.lower() in video_exts:
result["video_file_info"] = {
"filename": video_file.name,
"matches_nfo": episode_pattern in video_file.stem
}
if not (episode_pattern in video_file.stem):
result["recommendations"].append(f"Video filename '{video_file.name}' doesn't cleanly match NFO pattern")
break
# Element order analysis
element_order = [child.tag for child in root]
result["element_order"] = element_order
# Emby-specific title positioning checks
title_position = element_order.index('title') + 1 if 'title' in element_order else -1
result["title_position"] = title_position
if title_position > 1:
result["recommendations"].append(f"Title element is at position {title_position}. Emby prefers title as first element.")
# Check for common Emby compatibility issues
emby_checks = {
"title_first": element_order[0] == "title" if element_order else False,
"has_plot": "plot" in element_order,
"has_aired": "aired" in element_order,
"has_season": "season" in element_order,
"has_episode": "episode" in element_order,
"no_lockdata": "lockdata" not in element_order
}
result["emby_compatibility"] = emby_checks
# Additional Emby recommendations
if not emby_checks["title_first"]:
result["recommendations"].append("Move <title> to be the first element for best Emby compatibility")
if not emby_checks["no_lockdata"]:
result["recommendations"].append("Remove <lockdata> elements - they can cause Emby metadata issues")
# Check title content quality
if title_elem is not None and title_elem.text:
title_text = title_elem.text.strip()
# Check if title looks like filename
video_filename_base = None
for video_file in video_files:
if episode_pattern in video_file.stem:
video_filename_base = video_file.stem
break
if video_filename_base and title_text.lower() in video_filename_base.lower():
result["recommendations"].append(f"Title '{title_text}' appears to be derived from filename - ensure it's a proper episode title")
return result
except ET.ParseError as e:
with open(nfo_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
return {
"file_path": str(nfo_path),
"error": "XML Parse Error",
"parse_error": str(e),
"raw_content_sample": raw_content[:500]
}
except Exception as e:
_log("ERROR", f"TV episode debug failed: {e}")
raise HTTPException(status_code=500, detail=f"TV episode debug failed: {str(e)}")
@app.post("/debug/validate-series-nfos")
async def validate_series_nfos(request: Dict[str, Any]):
"""Validate NFO files for an entire TV series for Emby compatibility"""
try:
series_path_str = request.get("series_path")
if not series_path_str:
raise HTTPException(status_code=400, detail="series_path required")
series_path = Path(series_path_str)
if not series_path.exists():
raise HTTPException(status_code=404, detail="Series path not found")
_log("INFO", f"Validating series NFOs: {series_path}")
validation_results = {
"series_path": str(series_path),
"series_nfo": None,
"seasons": {},
"episodes": [],
"summary": {
"total_nfos": 0,
"emby_compatible": 0,
"title_position_issues": 0,
"lockdata_issues": 0,
"common_issues": []
}
}
# Check series NFO
tvshow_nfo = series_path / "tvshow.nfo"
if tvshow_nfo.exists():
validation_results["series_nfo"] = await debug_nfo_structure(str(tvshow_nfo), "series")
validation_results["summary"]["total_nfos"] += 1
# Check season NFOs and episodes
from core.fs_cache import fs_cache
season_dirs = [d for d in fs_cache.get_directory_contents(series_path)
if d.is_dir() and d.name.lower().startswith("season")]
for season_dir in season_dirs:
season_name = season_dir.name
season_data = {"path": str(season_dir), "nfo": None, "episodes": []}
# Check season NFO
season_nfo = season_dir / "season.nfo"
if season_nfo.exists():
season_data["nfo"] = await debug_nfo_structure(str(season_nfo), "season")
validation_results["summary"]["total_nfos"] += 1
# Check episode NFOs
video_files = fs_cache.find_video_files(season_dir)
for video_file in video_files:
nfo_file = video_file.with_suffix('.nfo')
if nfo_file.exists():
episode_debug = await debug_tv_episode_nfo(str(nfo_file).replace(str(series_path) + "/", ""))
season_data["episodes"].append(episode_debug)
validation_results["episodes"].append(episode_debug)
validation_results["summary"]["total_nfos"] += 1
# Analyze for summary
if "emby_compatibility" in episode_debug:
emby_compat = episode_debug["emby_compatibility"]
if all(emby_compat.values()):
validation_results["summary"]["emby_compatible"] += 1
if not emby_compat.get("title_first", False):
validation_results["summary"]["title_position_issues"] += 1
if not emby_compat.get("no_lockdata", True):
validation_results["summary"]["lockdata_issues"] += 1
validation_results["seasons"][season_name] = season_data
# Generate common issues summary
if validation_results["summary"]["title_position_issues"] > 0:
validation_results["summary"]["common_issues"].append(
f"{validation_results['summary']['title_position_issues']} NFOs have title positioning issues"
)
if validation_results["summary"]["lockdata_issues"] > 0:
validation_results["summary"]["common_issues"].append(
f"{validation_results['summary']['lockdata_issues']} NFOs contain lockdata elements"
)
compat_rate = (validation_results["summary"]["emby_compatible"] /
validation_results["summary"]["total_nfos"] * 100) if validation_results["summary"]["total_nfos"] > 0 else 0
validation_results["summary"]["compatibility_rate"] = round(compat_rate, 1)
return validation_results
except Exception as e:
_log("ERROR", f"Series NFO validation failed: {e}")
raise HTTPException(status_code=500, detail=f"Series NFO validation failed: {str(e)}")
async def debug_nfo_structure(nfo_path_str: str, nfo_type: str) -> Dict[str, Any]:
"""Helper function to debug NFO structure"""
try:
nfo_path = Path(nfo_path_str)
if not nfo_path.exists():
return {"error": "NFO file not found", "path": nfo_path_str}
import xml.etree.ElementTree as ET
tree = ET.parse(nfo_path)
root = tree.getroot()
# Basic structure analysis
result = {
"file_path": str(nfo_path),
"root_element": root.tag,
"element_count": len(root),
"elements": {},
"recommendations": []
}
# Element analysis
for child in root:
if child.text:
result["elements"][child.tag] = child.text.strip()[:100] # Truncate long values
else:
result["elements"][child.tag] = None
# Element order analysis
element_order = [child.tag for child in root]
result["element_order"] = element_order
# Common checks
if "title" in element_order:
title_position = element_order.index('title') + 1
result["title_position"] = title_position
if title_position > 1:
result["recommendations"].append(f"Title element is at position {title_position}. Consider moving to position 1.")
if "lockdata" in element_order:
result["recommendations"].append("Contains lockdata element - consider removing for Emby compatibility")
return result
except Exception as e:
return {"error": str(e), "path": nfo_path_str}
@app.post("/debug/fix-emby-compatibility")
async def fix_emby_compatibility(request: Dict[str, Any]):
"""Apply Emby compatibility fixes to NFO files"""
try:
file_path_str = request.get("file_path")
series_path_str = request.get("series_path")
if not file_path_str and not series_path_str:
raise HTTPException(status_code=400, detail="Either file_path or series_path required")
results = {
"fixed_files": [],
"total_processed": 0,
"total_updated": 0,
"errors": []
}
nfo_files = []
# Single file fix
if file_path_str:
file_path = Path(file_path_str)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if file_path.suffix.lower() == '.nfo':
nfo_files.append(file_path)
# Series directory fix
if series_path_str:
series_path = Path(series_path_str)
if not series_path.exists():
raise HTTPException(status_code=404, detail="Series path not found")
# Find all NFO files in series directory
from core.fs_cache import fs_cache
# Series NFO
tvshow_nfo = series_path / "tvshow.nfo"
if tvshow_nfo.exists():
nfo_files.append(tvshow_nfo)
# Season and episode NFOs
season_dirs = [d for d in fs_cache.get_directory_contents(series_path)
if d.is_dir() and d.name.lower().startswith("season")]
for season_dir in season_dirs:
# Season NFO
season_nfo = season_dir / "season.nfo"
if season_nfo.exists():
nfo_files.append(season_nfo)
# Episode NFOs
video_files = fs_cache.find_video_files(season_dir)
for video_file in video_files:
nfo_file = video_file.with_suffix('.nfo')
if nfo_file.exists():
nfo_files.append(nfo_file)
# Apply fixes to all found NFO files
for nfo_file in nfo_files:
try:
fix_results = nfo_manager.fix_nfo_for_emby_compatibility(nfo_file)
file_result = {
"file_path": str(nfo_file),
"lockdata_removed": fix_results["lockdata_removed"],
"title_reordered": fix_results["title_reordered"],
"updated": fix_results["file_updated"]
}
results["fixed_files"].append(file_result)
results["total_processed"] += 1
if fix_results["file_updated"]:
results["total_updated"] += 1
except Exception as e:
error_msg = f"Error fixing {nfo_file}: {str(e)}"
results["errors"].append(error_msg)
_log("ERROR", error_msg)
_log("INFO", f"Emby compatibility fixes complete: {results['total_updated']} of {results['total_processed']} files updated")
return results
except Exception as e:
_log("ERROR", f"Emby compatibility fix failed: {e}")
raise HTTPException(status_code=500, detail=f"Emby compatibility fix failed: {str(e)}")