feat: Preserve TV episode NFO filenames matching video files
Local Docker Build (Dev) / build-dev (push) Successful in 29s
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:
+482
-1
@@ -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)}")
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
import re
|
||||
from .logging import _log
|
||||
|
||||
|
||||
class NFOManager:
|
||||
@@ -858,6 +859,71 @@ class NFOManager:
|
||||
|
||||
return False
|
||||
|
||||
def reorder_nfo_for_emby(self, nfo_path: Path) -> bool:
|
||||
"""Reorder NFO elements to put title first for better Emby compatibility"""
|
||||
if not nfo_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Find title element
|
||||
title_elem = root.find('title')
|
||||
if title_elem is None:
|
||||
return False # No title element to reorder
|
||||
|
||||
# Check if title is already first
|
||||
if len(root) > 0 and root[0].tag == 'title':
|
||||
return False # Title is already first
|
||||
|
||||
# Remove title from current position
|
||||
root.remove(title_elem)
|
||||
|
||||
# Insert title as first element
|
||||
root.insert(0, title_elem)
|
||||
|
||||
# Write back the modified NFO
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
|
||||
_log("INFO", f"Reordered NFO elements - moved title to first position: {nfo_path.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error reordering NFO {nfo_path}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def fix_nfo_for_emby_compatibility(self, nfo_path: Path) -> Dict[str, bool]:
|
||||
"""Apply all Emby compatibility fixes to an NFO file"""
|
||||
results = {
|
||||
"lockdata_removed": False,
|
||||
"title_reordered": False,
|
||||
"file_updated": False
|
||||
}
|
||||
|
||||
if not nfo_path.exists():
|
||||
return results
|
||||
|
||||
try:
|
||||
# Strip lockdata elements
|
||||
results["lockdata_removed"] = self.strip_lockdata_from_nfo(nfo_path)
|
||||
|
||||
# Reorder title to first position
|
||||
results["title_reordered"] = self.reorder_nfo_for_emby(nfo_path)
|
||||
|
||||
results["file_updated"] = results["lockdata_removed"] or results["title_reordered"]
|
||||
|
||||
if results["file_updated"]:
|
||||
_log("INFO", f"Applied Emby compatibility fixes to {nfo_path.name}: "
|
||||
f"lockdata_removed={results['lockdata_removed']}, "
|
||||
f"title_reordered={results['title_reordered']}")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error applying Emby fixes to {nfo_path}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def strip_lockdata_from_directory(self, directory_path: Path) -> int:
|
||||
"""Strip lockdata from all NFO files in a directory"""
|
||||
if not directory_path.exists():
|
||||
@@ -874,4 +940,111 @@ class NFOManager:
|
||||
|
||||
_log("INFO", f"Stripped lockdata from {processed_count} NFO files in {directory_path}")
|
||||
return processed_count
|
||||
|
||||
def update_episode_nfo_preserving_name(self, nfo_path: Path, season_num: int, episode_num: int,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str) -> bool:
|
||||
"""Update existing TV episode NFO file while preserving its original filename"""
|
||||
if not nfo_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Parse the existing NFO file
|
||||
tree = ET.parse(nfo_path)
|
||||
episode = tree.getroot()
|
||||
|
||||
# Ensure root element is <episodedetails>
|
||||
if episode.tag != "episodedetails":
|
||||
print(f"⚠️ NFO file {nfo_path.name} does not have episodedetails root element")
|
||||
return False
|
||||
|
||||
# Remove any existing NFOGuard fields to re-add them at the bottom
|
||||
nfoguard_fields = ["aired", "dateadded", "season", "episode"]
|
||||
for field_name in nfoguard_fields:
|
||||
existing_field = episode.find(field_name)
|
||||
if existing_field is not None:
|
||||
episode.remove(existing_field)
|
||||
|
||||
# Remove any existing NFOGuard comments
|
||||
for child in episode:
|
||||
if isinstance(child, ET.Comment):
|
||||
if self.manager_brand in str(child):
|
||||
episode.remove(child)
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Basic episode info at the end
|
||||
season_elem = ET.SubElement(episode, "season")
|
||||
season_elem.text = str(season_num)
|
||||
|
||||
episode_elem = ET.SubElement(episode, "episode")
|
||||
episode_elem.text = str(episode_num)
|
||||
|
||||
# Dates at the end
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode, "aired")
|
||||
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
|
||||
# Add NFOGuard comment at the bottom with other NFOGuard fields
|
||||
nfoguard_comment = ET.Comment(f" Updated by {self.manager_brand} - Source: {source} ")
|
||||
episode.append(nfoguard_comment)
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(episode)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully updated episode NFO (preserving name): {nfo_path.name}")
|
||||
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating episode NFO {nfo_path.name}: {e}")
|
||||
return False
|
||||
|
||||
def find_episode_nfo_matching_video(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||
"""Find episode NFO that matches the naming pattern of corresponding video files"""
|
||||
if not season_dir.exists():
|
||||
return None
|
||||
|
||||
# Find video files for this episode (check common patterns)
|
||||
season_episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
|
||||
|
||||
# Look for video files with season/episode pattern
|
||||
video_extensions = ['.mkv', '.mp4', '.avi', '.m4v']
|
||||
for ext in video_extensions:
|
||||
video_files = season_dir.glob(f"*{season_episode_pattern}*{ext}")
|
||||
for video_file in video_files:
|
||||
# For each video file, look for an NFO with the same base name
|
||||
nfo_path = video_file.with_suffix('.nfo')
|
||||
if nfo_path.exists():
|
||||
# Verify this NFO contains the right season/episode
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag == "episodedetails":
|
||||
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 episode NFO matching video file: {nfo_path.name}")
|
||||
return nfo_path
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
+29
-11
@@ -87,13 +87,31 @@ class TVProcessor:
|
||||
# Process episodes
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in disk_episodes:
|
||||
# Create NFO
|
||||
# Create or update NFO
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Check if this is an existing NFO that needs updating while preserving name
|
||||
if source == "nfo_update_required":
|
||||
# Find the existing NFO file and update it in place
|
||||
existing_nfo = self.nfo_manager.find_episode_nfo_matching_video(season_dir, season, episode)
|
||||
if existing_nfo:
|
||||
# Update existing NFO while preserving its filename
|
||||
self.nfo_manager.update_episode_nfo_preserving_name(
|
||||
existing_nfo, season, episode, aired, dateadded, "file_update"
|
||||
)
|
||||
else:
|
||||
# Fallback to standard creation if NFO not found
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
else:
|
||||
# Standard NFO creation for new episodes
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
@@ -170,21 +188,21 @@ class TVProcessor:
|
||||
if key in disk_episodes: # Only use cached data for episodes we have
|
||||
episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"])
|
||||
|
||||
# Check for existing NFO files (including long-named ones) for migration
|
||||
# Check for existing NFO files that match video file names (preserve long names)
|
||||
nfo_episodes_found = 0
|
||||
for (season_num, episode_num) in disk_episodes.keys():
|
||||
if (season_num, episode_num) not in episode_dates:
|
||||
# Check if this episode has an existing NFO file that needs migration
|
||||
# Check if this episode has an NFO file matching its video file name
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
existing_nfo = self.nfo_manager.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||
existing_nfo = self.nfo_manager.find_episode_nfo_matching_video(season_dir, season_num, episode_num)
|
||||
|
||||
if existing_nfo:
|
||||
# Force processing of this episode for NFO migration
|
||||
episode_dates[(season_num, episode_num)] = (None, None, "nfo_migration_required")
|
||||
# Force processing of this episode to update NFO with NFOGuard data
|
||||
episode_dates[(season_num, episode_num)] = (None, None, "nfo_update_required")
|
||||
nfo_episodes_found += 1
|
||||
|
||||
if nfo_episodes_found > 0:
|
||||
_log("INFO", f"Found {nfo_episodes_found} episodes with existing NFO files requiring migration")
|
||||
_log("INFO", f"Found {nfo_episodes_found} episodes with existing NFO files to update (preserving names)")
|
||||
|
||||
# Find missing episodes (not in cache and no existing NFO)
|
||||
cached_keys = set(episode_dates.keys())
|
||||
|
||||
Reference in New Issue
Block a user