e3a119efd7
Local Docker Build (Dev) / build-dev (push) Successful in 19s
Root cause identified: update_episode_nfo_preserving_name() was receiving None values for aired/dateadded and just updating NFO with basic fields. Fixed by: - Adding automatic data fetching when missing episode data detected - Passing imdb_id and tv_processor to enable API calls - Added fallback logic to fetch from database/Sonarr when data missing - Will show clear debug output when fetching missing data Now when NFO files are updated, they will automatically get proper dateadded and aired fields from the NFOGuard database or Sonarr API. This should resolve the missing NFOGuard metadata in episode NFO files.
1099 lines
51 KiB
Python
1099 lines
51 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
NFO Manager for creating and managing metadata files
|
|
Handles NFO creation for movies, TV shows, seasons, and episodes
|
|
"""
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Optional, Dict, Any
|
|
import re
|
|
from .logging import _log
|
|
|
|
|
|
class NFOManager:
|
|
"""Manages NFO file creation and updates"""
|
|
|
|
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
|
self.manager_brand = manager_brand
|
|
self.debug = debug
|
|
|
|
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
|
"""Extract IMDb ID from directory path or filename"""
|
|
# Look for various IMDb patterns in both directory and file names
|
|
path_str = str(path).lower()
|
|
|
|
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
|
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
# Try standalone [ttXXXXXXX] format in brackets
|
|
match = re.search(r'\[(tt\d+)\]', path_str)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
# Try {imdb-ttXXXXXXX} format with curly braces
|
|
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
# Try (imdb-ttXXXXXXX) format with parentheses
|
|
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
|
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
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:
|
|
root = self._parse_nfo_with_tolerance(nfo_path)
|
|
|
|
# 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
|
|
|
|
# Last resort: Check for TMDB ID as fallback identifier
|
|
# This handles movies that only have TMDB IDs in NFO files
|
|
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
|
|
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
|
|
tmdb_id = tmdb_uniqueid.text.strip()
|
|
if tmdb_id.isdigit():
|
|
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
|
|
# Return TMDB ID with prefix to distinguish from IMDb IDs
|
|
return f"tmdb-{tmdb_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"""
|
|
print(f"🔍 Searching for IMDb ID in: {movie_dir.name}")
|
|
|
|
# First try directory name
|
|
imdb_id = self.parse_imdb_from_path(movie_dir)
|
|
if imdb_id:
|
|
print(f"✅ Found IMDb ID in directory name: {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:
|
|
print(f"✅ Found IMDb ID in filename: {imdb_id} from {file_path.name}")
|
|
return imdb_id
|
|
|
|
# Finally, try NFO file content (including TMDB fallback)
|
|
nfo_path = movie_dir / "movie.nfo"
|
|
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
|
if imdb_id:
|
|
if imdb_id.startswith("tmdb-"):
|
|
print(f"✅ Found TMDB ID in NFO file: {imdb_id} from {nfo_path} (fallback mode)")
|
|
else:
|
|
print(f"✅ Found IMDb ID in NFO file: {imdb_id} from {nfo_path}")
|
|
return imdb_id
|
|
|
|
print(f"❌ No IMDb or TMDB ID found in directory, filenames, or NFO for: {movie_dir.name}")
|
|
return None
|
|
|
|
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
|
"""Extract NFOGuard-managed dates from existing NFO file"""
|
|
if not nfo_path.exists():
|
|
return None
|
|
|
|
try:
|
|
tree = ET.parse(nfo_path)
|
|
root = tree.getroot()
|
|
|
|
# Look for NFOGuard fields
|
|
dateadded_elem = root.find('.//dateadded')
|
|
premiered_elem = root.find('.//premiered')
|
|
# Strip any existing lockdata elements for Emby compatibility
|
|
lockdata_elements = root.findall('.//lockdata')
|
|
if lockdata_elements:
|
|
modified = False
|
|
for lockdata_elem in lockdata_elements:
|
|
# Find parent and remove the element
|
|
for parent in root.iter():
|
|
if lockdata_elem in parent:
|
|
parent.remove(lockdata_elem)
|
|
modified = True
|
|
_log("DEBUG", f"Stripped lockdata element from {nfo_path.name}")
|
|
break
|
|
if modified:
|
|
# Write back the cleaned NFO
|
|
tree = ET.ElementTree(root)
|
|
ET.indent(tree, space=" ", level=0)
|
|
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
|
|
|
|
# Only consider it NFOGuard-managed if it has dateadded (lockdata no longer required)
|
|
if (dateadded_elem is not None and dateadded_elem.text):
|
|
|
|
result = {
|
|
"dateadded": dateadded_elem.text.strip(),
|
|
"source": "nfo_file_existing"
|
|
}
|
|
|
|
if premiered_elem is not None and premiered_elem.text:
|
|
result["released"] = premiered_elem.text.strip()
|
|
|
|
print(f"✅ Found NFOGuard data in NFO: dateadded={result['dateadded']}, released={result.get('released', 'None')}")
|
|
return result
|
|
|
|
except (ET.ParseError, Exception) as e:
|
|
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
|
|
pass
|
|
|
|
return None
|
|
|
|
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
|
|
"""Extract NFOGuard-managed dates from existing episode NFO file"""
|
|
nfo_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
|
nfo_path = season_path / nfo_filename
|
|
|
|
if not nfo_path.exists():
|
|
return None
|
|
|
|
try:
|
|
tree = ET.parse(nfo_path)
|
|
root = tree.getroot()
|
|
|
|
# Look for NFOGuard fields in episode NFO
|
|
dateadded_elem = root.find('.//dateadded')
|
|
aired_elem = root.find('.//aired')
|
|
# Strip any existing lockdata elements for Emby compatibility
|
|
lockdata_elements = root.findall('.//lockdata')
|
|
if lockdata_elements:
|
|
modified = False
|
|
for lockdata_elem in lockdata_elements:
|
|
# Find parent and remove the element
|
|
for parent in root.iter():
|
|
if lockdata_elem in parent:
|
|
parent.remove(lockdata_elem)
|
|
modified = True
|
|
_log("DEBUG", f"Stripped lockdata element from {nfo_path.name}")
|
|
break
|
|
if modified:
|
|
# Write back the cleaned NFO
|
|
tree = ET.ElementTree(root)
|
|
ET.indent(tree, space=" ", level=0)
|
|
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
|
|
|
|
# Only consider it NFOGuard-managed if it has dateadded (lockdata no longer required)
|
|
if (dateadded_elem is not None and dateadded_elem.text):
|
|
|
|
result = {
|
|
"dateadded": dateadded_elem.text.strip(),
|
|
"source": "episode_nfo_existing"
|
|
}
|
|
|
|
if aired_elem is not None and aired_elem.text:
|
|
result["aired"] = aired_elem.text.strip()
|
|
|
|
# Check if title is missing
|
|
title_elem = root.find('.//title')
|
|
result["has_title"] = title_elem is not None and title_elem.text and title_elem.text.strip()
|
|
|
|
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result['dateadded']}, aired={result.get('aired', 'None')}, has_title={result['has_title']}")
|
|
return result
|
|
|
|
except (ET.ParseError, Exception) as e:
|
|
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
|
|
pass
|
|
|
|
return None
|
|
|
|
def enhance_existing_episode_nfo_with_title(self, season_path: Path, season_num: int, episode_num: int, title: str) -> bool:
|
|
"""Add title to existing episode NFO file that's missing one"""
|
|
nfo_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
|
nfo_path = season_path / nfo_filename
|
|
|
|
if not nfo_path.exists():
|
|
return False
|
|
|
|
try:
|
|
tree = ET.parse(nfo_path)
|
|
root = tree.getroot()
|
|
|
|
# Check if title already exists
|
|
existing_title = root.find('.//title')
|
|
if existing_title is not None and existing_title.text and existing_title.text.strip():
|
|
return False # Title already exists
|
|
|
|
# Add title element (insert near the top, after any existing plot element)
|
|
title_elem = ET.Element("title")
|
|
title_elem.text = title
|
|
|
|
# Find the best position to insert title (after plot if it exists, otherwise at the beginning)
|
|
plot_elem = root.find('.//plot')
|
|
if plot_elem is not None:
|
|
# Insert after plot
|
|
plot_index = list(root).index(plot_elem)
|
|
root.insert(plot_index + 1, title_elem)
|
|
else:
|
|
# Insert at the beginning (after any existing title that might be empty)
|
|
if existing_title is not None:
|
|
title_index = list(root).index(existing_title)
|
|
root.remove(existing_title)
|
|
root.insert(title_index, title_elem)
|
|
else:
|
|
root.insert(0, title_elem)
|
|
|
|
# Write the updated NFO
|
|
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
|
print(f"📝 Enhanced existing NFO with title: S{season_num:02d}E{episode_num:02d} - '{title}'")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Error enhancing NFO with title: {e}")
|
|
return False
|
|
|
|
def _extract_title_from_filename(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[str]:
|
|
"""Extract episode title from video filename as fallback when metadata doesn't provide it"""
|
|
try:
|
|
import re
|
|
# Look for video files matching this episode
|
|
season_pattern = f"S{season_num:02d}E{episode_num:02d}"
|
|
print(f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}")
|
|
|
|
for video_file in season_dir.glob("*.mkv"):
|
|
filename = video_file.name
|
|
print(f"🔍 Checking file: {filename}")
|
|
if season_pattern in filename:
|
|
print(f"✅ Found matching file: {filename}")
|
|
# Pattern: SeriesName-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv
|
|
# Extract the part between season/episode and the first bracket
|
|
pattern = rf'{season_pattern}-(.*?)\['
|
|
print(f"🔍 Using regex pattern: {pattern}")
|
|
match = re.search(pattern, filename)
|
|
if match:
|
|
title = match.group(1).strip()
|
|
print(f"🔍 Raw extracted title: '{title}'")
|
|
# Clean up common encoding artifacts and separators
|
|
title = title.replace('-', ' ').strip()
|
|
if title:
|
|
print(f"✅ Extracted title from filename: '{title}' for {season_pattern}")
|
|
return title
|
|
else:
|
|
print(f"⚠️ Title was empty after cleanup")
|
|
else:
|
|
print(f"⚠️ Regex pattern didn't match filename")
|
|
|
|
# Also check .mp4 files
|
|
for video_file in season_dir.glob("*.mp4"):
|
|
filename = video_file.name
|
|
print(f"🔍 Checking .mp4 file: {filename}")
|
|
if season_pattern in filename:
|
|
print(f"✅ Found matching .mp4 file: {filename}")
|
|
pattern = rf'{season_pattern}-(.*?)\['
|
|
match = re.search(pattern, filename)
|
|
if match:
|
|
title = match.group(1).strip()
|
|
print(f"🔍 Raw extracted title from .mp4: '{title}'")
|
|
title = title.replace('-', ' ').strip()
|
|
if title:
|
|
print(f"✅ Extracted title from .mp4 filename: '{title}' for {season_pattern}")
|
|
return title
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}")
|
|
|
|
print(f"⚠️ No title found in filenames for {season_pattern}")
|
|
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,
|
|
released: Optional[str] = None, source: str = "unknown",
|
|
lock_metadata: bool = True) -> None:
|
|
"""Create or update movie.nfo file preserving existing content"""
|
|
nfo_path = movie_dir / "movie.nfo"
|
|
|
|
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
|
|
|
|
try:
|
|
# Try to load existing NFO file
|
|
if nfo_path.exists():
|
|
try:
|
|
# Try to parse the XML, handling URLs appended after </movie>
|
|
movie = self._parse_nfo_with_tolerance(nfo_path)
|
|
|
|
# Ensure root element is <movie>
|
|
if movie.tag != "movie":
|
|
raise ValueError("Root element is not <movie>")
|
|
|
|
# Remove existing NFOGuard-managed elements to avoid duplicates
|
|
# These will be re-added at the very bottom
|
|
nfoguard_fields = ["dateadded", "premiered", "year"]
|
|
for tag in nfoguard_fields:
|
|
existing = movie.find(tag)
|
|
if existing is not None:
|
|
# Store the value before removing (for premiered/year)
|
|
if tag == "premiered" and not released:
|
|
print(f"🔍 Preserving existing premiered date: {existing.text} (released was None)")
|
|
released = existing.text # Preserve existing premiered date
|
|
elif tag == "premiered":
|
|
print(f"🔍 NOT preserving premiered date: existing={existing.text}, released={released}")
|
|
movie.remove(existing)
|
|
|
|
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
|
# We'll add a clean one at the bottom
|
|
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
|
movie.remove(uniqueid)
|
|
|
|
except (ET.ParseError, ValueError) as e:
|
|
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
|
|
print(f" Creating new clean NFO file to replace corrupted one")
|
|
movie = ET.Element("movie")
|
|
else:
|
|
# Create new NFO structure
|
|
movie = ET.Element("movie")
|
|
|
|
# Now append ALL NFOGuard fields at the VERY END of the file
|
|
# Create elements and explicitly append them to ensure they're at the bottom
|
|
|
|
# Add NFOGuard comment marker as the first of our additions
|
|
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
|
|
movie.append(nfoguard_comment)
|
|
|
|
# Add IMDb uniqueid at the end (after all existing content)
|
|
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
|
|
uniqueid.text = imdb_id
|
|
movie.append(uniqueid)
|
|
|
|
# Add premiered date at the bottom if we have it
|
|
if released:
|
|
premiered_elem = ET.Element("premiered")
|
|
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
|
movie.append(premiered_elem)
|
|
|
|
# Extract year from premiered date for consistency
|
|
try:
|
|
year_value = released[:4] if len(released) >= 4 else None
|
|
if year_value and year_value.isdigit():
|
|
year_elem = ET.Element("year")
|
|
year_elem.text = year_value
|
|
movie.append(year_elem)
|
|
except:
|
|
pass # Skip year if we can't extract it
|
|
|
|
# Add dateadded at the end - THIS IS CRITICAL FOR EMBY PLUGIN
|
|
if dateadded:
|
|
dateadded_elem = ET.Element("dateadded")
|
|
dateadded_elem.text = dateadded
|
|
movie.append(dateadded_elem)
|
|
print(f"✅ Added dateadded to NFO: {dateadded}")
|
|
|
|
# Note: lockdata element removed for Emby compatibility
|
|
|
|
# Write file with proper formatting
|
|
tree = ET.ElementTree(movie)
|
|
ET.indent(tree, space=" ", level=0)
|
|
|
|
# Write directly to file (comment is already embedded in XML)
|
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
|
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
|
|
tree.write(f, encoding='unicode', xml_declaration=False)
|
|
|
|
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
|
|
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
|
|
|
|
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
|
"""Create or update tvshow.nfo file preserving existing content"""
|
|
nfo_path = series_dir / "tvshow.nfo"
|
|
|
|
try:
|
|
# Try to load existing NFO file
|
|
if nfo_path.exists():
|
|
try:
|
|
tree = ET.parse(nfo_path)
|
|
tvshow = tree.getroot()
|
|
|
|
# Ensure root element is <tvshow>
|
|
if tvshow.tag != "tvshow":
|
|
raise ValueError("Root element is not <tvshow>")
|
|
|
|
# Remove existing NFOGuard-managed elements to avoid duplicates
|
|
# These will be re-added at the bottom
|
|
for tag in ["lockdata"]:
|
|
existing = tvshow.find(tag)
|
|
if existing is not None:
|
|
tvshow.remove(existing)
|
|
|
|
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
|
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
|
|
tvshow.remove(uniqueid)
|
|
|
|
except (ET.ParseError, ValueError) as e:
|
|
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
|
|
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
|
|
tvshow = ET.Element("tvshow")
|
|
else:
|
|
# Create new NFO structure
|
|
tvshow = ET.Element("tvshow")
|
|
|
|
# Add NFOGuard fields at the bottom
|
|
|
|
# Add IMDb uniqueid at the end
|
|
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
|
|
imdb_uniqueid.text = imdb_id
|
|
|
|
# Add TVDB ID if available (preserve existing or add new)
|
|
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
|
|
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
|
|
tvdb_uniqueid.text = tvdb_id
|
|
|
|
# Note: lockdata element removed for Emby compatibility
|
|
|
|
# Add NFOGuard comment at the beginning
|
|
comment_text = f" Created by {self.manager_brand} "
|
|
|
|
# Write file with proper formatting
|
|
tree = ET.ElementTree(tvshow)
|
|
ET.indent(tree, space=" ", level=0)
|
|
|
|
# Write to string first to add comment
|
|
xml_str = ET.tostring(tvshow, encoding='unicode')
|
|
|
|
# Add XML declaration and comment
|
|
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
|
|
|
# Write to file
|
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
|
f.write(full_xml)
|
|
|
|
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
|
|
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
|
|
|
|
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
|
"""Create or update season.nfo file preserving existing content"""
|
|
nfo_path = season_dir / "season.nfo"
|
|
|
|
try:
|
|
season_dir.mkdir(exist_ok=True)
|
|
|
|
# Try to load existing NFO file
|
|
if nfo_path.exists():
|
|
try:
|
|
tree = ET.parse(nfo_path)
|
|
season = tree.getroot()
|
|
|
|
# Ensure root element is <season>
|
|
if season.tag != "season":
|
|
raise ValueError("Root element is not <season>")
|
|
|
|
# Remove existing NFOGuard-managed elements
|
|
for tag in ["seasonnumber"]:
|
|
existing = season.find(tag)
|
|
if existing is not None:
|
|
season.remove(existing)
|
|
|
|
except (ET.ParseError, ValueError) as e:
|
|
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
|
|
print(f" Creating new clean season.nfo file to replace corrupted one")
|
|
season = ET.Element("season")
|
|
else:
|
|
# Create new NFO structure
|
|
season = ET.Element("season")
|
|
|
|
# Add NFOGuard fields at the bottom
|
|
seasonnumber = ET.SubElement(season, "seasonnumber")
|
|
seasonnumber.text = str(season_number)
|
|
|
|
# Note: lockdata element removed for Emby compatibility
|
|
|
|
# Add NFOGuard comment at the beginning
|
|
comment_text = f" Created by {self.manager_brand} "
|
|
|
|
# Write file with proper formatting
|
|
tree = ET.ElementTree(season)
|
|
ET.indent(tree, space=" ", level=0)
|
|
|
|
# Write to string first to add comment
|
|
xml_str = ET.tostring(season, encoding='unicode')
|
|
|
|
# Add XML declaration and comment
|
|
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
|
|
|
# Write to file
|
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
|
f.write(full_xml)
|
|
|
|
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
|
print(f" Season: {season_number}")
|
|
|
|
except Exception as 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,
|
|
aired: Optional[str], dateadded: Optional[str], source: str,
|
|
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
"""Create or update episode NFO file - preserves existing long names matching video files"""
|
|
|
|
try:
|
|
# First, check for NFO files that match video file names (preserve long names)
|
|
existing_matching_nfo = self.find_episode_nfo_matching_video(season_dir, season_num, episode_num)
|
|
|
|
if existing_matching_nfo:
|
|
# Update the existing NFO in-place while preserving its filename
|
|
print(f"🎯 Updating existing NFO (preserving name): {existing_matching_nfo.name}")
|
|
success = self.update_episode_nfo_preserving_name(
|
|
existing_matching_nfo, season_num, episode_num, aired, dateadded, source
|
|
)
|
|
if success:
|
|
return # Successfully updated, we're done
|
|
else:
|
|
print(f"⚠️ Failed to update existing NFO, falling back to standard creation")
|
|
|
|
# Check for and remove old short-name NFO files (S01E01.nfo format)
|
|
short_nfo_path = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
|
if short_nfo_path.exists():
|
|
print(f"🗑️ Removing old short-name NFO: {short_nfo_path.name}")
|
|
try:
|
|
short_nfo_path.unlink()
|
|
print(f"✅ Deleted short-name NFO file: {short_nfo_path.name}")
|
|
except Exception as e:
|
|
print(f"⚠️ Warning: Could not delete short-name NFO {short_nfo_path.name}: {e}")
|
|
|
|
# Fallback: Create NFO file matching video file name (no more short S01E01.nfo names)
|
|
video_file = self.find_video_file_for_episode(season_dir, season_num, episode_num)
|
|
if video_file:
|
|
# Create NFO with same name as video file
|
|
nfo_path = video_file.with_suffix('.nfo')
|
|
print(f"🎬 Creating new NFO matching video file: {nfo_path.name}")
|
|
else:
|
|
# Last resort: create with season/episode pattern (but this should rarely happen)
|
|
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
|
nfo_path = season_dir / episode_filename
|
|
print(f"⚠️ No video file found, creating basic NFO: {episode_filename}")
|
|
|
|
# Check if NFO already exists
|
|
source_nfo_path = nfo_path if nfo_path.exists() else None
|
|
|
|
if source_nfo_path:
|
|
# Standard NFO file processing (S01E01.nfo format)
|
|
try:
|
|
tree = ET.parse(source_nfo_path)
|
|
episode = tree.getroot()
|
|
|
|
# Ensure root element is <episodedetails>
|
|
if episode.tag != "episodedetails":
|
|
raise ValueError("Root element is not <episodedetails>")
|
|
|
|
print(f"📝 Updating existing NFO: {nfo_path.name}")
|
|
|
|
# Remove existing NFOGuard fields to re-add them at the bottom
|
|
nfoguard_fields = ["aired", "dateadded", "season", "episode"]
|
|
for tag in nfoguard_fields:
|
|
existing = episode.find(tag)
|
|
if existing is not None:
|
|
# Preserve existing aired date if not provided
|
|
if tag == "aired" and not aired:
|
|
aired = existing.text
|
|
episode.remove(existing)
|
|
|
|
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
|
|
# These should be preserved from the long-named NFO files
|
|
|
|
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
|
|
if self.debug:
|
|
preserved_fields = [elem.tag for elem in episode]
|
|
if preserved_fields:
|
|
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
|
|
else:
|
|
print(f" ⚠️ No content fields found after cleanup!")
|
|
|
|
except (ET.ParseError, ValueError) as e:
|
|
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
|
|
print(f" Creating new clean episode NFO file to replace corrupted one")
|
|
episode = ET.Element("episodedetails")
|
|
else:
|
|
# Create new NFO structure
|
|
episode = ET.Element("episodedetails")
|
|
|
|
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
|
|
if enhanced_metadata:
|
|
if enhanced_metadata.get("title") and not episode.find("title"):
|
|
title_elem = ET.SubElement(episode, "title")
|
|
title_elem.text = enhanced_metadata["title"]
|
|
|
|
if enhanced_metadata.get("overview") and not episode.find("plot"):
|
|
plot_elem = ET.SubElement(episode, "plot")
|
|
plot_elem.text = enhanced_metadata["overview"]
|
|
|
|
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
|
|
runtime_elem = ET.SubElement(episode, "runtime")
|
|
runtime_elem.text = str(enhanced_metadata["runtime"])
|
|
|
|
# Fallback: Extract title from filename if no valid title present
|
|
title_elem = episode.find("title")
|
|
has_valid_title = title_elem is not None and title_elem.text and title_elem.text.strip()
|
|
|
|
print(f"🔍 Title check for S{season_num:02d}E{episode_num:02d}: has_element={title_elem is not None}, has_text={title_elem.text if title_elem is not None else 'N/A'}, has_valid_title={has_valid_title}")
|
|
|
|
if not has_valid_title:
|
|
print(f"🔍 No valid title found in NFO for S{season_num:02d}E{episode_num:02d}, attempting filename extraction")
|
|
filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num)
|
|
if filename_title:
|
|
# Remove existing empty/invalid title element if present
|
|
if title_elem is not None:
|
|
episode.remove(title_elem)
|
|
|
|
# Add new title element
|
|
title_elem = ET.SubElement(episode, "title")
|
|
title_elem.text = filename_title
|
|
print(f"✅ Added filename-extracted title to NFO: S{season_num:02d}E{episode_num:02d} - '{filename_title}'")
|
|
else:
|
|
print(f"⚠️ Could not extract title from filename for S{season_num:02d}E{episode_num:02d}")
|
|
else:
|
|
print(f"✅ Valid title already exists in NFO for S{season_num:02d}E{episode_num:02d}: '{title_elem.text.strip()}')")
|
|
|
|
# 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
|
|
|
|
# Note: lockdata element removed for Emby compatibility
|
|
|
|
# Add NFOGuard comment at the bottom with other NFOGuard fields
|
|
nfoguard_comment = ET.Comment(f" Created 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)
|
|
|
|
# Write to file normally (ET will handle the comment properly)
|
|
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
|
|
|
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
|
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
|
|
|
# No cleanup needed - we preserve existing NFO filenames
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
|
|
|
def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
|
|
"""Set file modification time to match import date"""
|
|
try:
|
|
# Parse ISO timestamp
|
|
if iso_timestamp.endswith('Z'):
|
|
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
|
elif '+' in iso_timestamp or 'T' in iso_timestamp:
|
|
dt = datetime.fromisoformat(iso_timestamp)
|
|
else:
|
|
# Assume it's already a simple date
|
|
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
|
|
|
|
# Convert to timestamp
|
|
timestamp = dt.timestamp()
|
|
|
|
# Set both access and modification times
|
|
os.utime(file_path, (timestamp, timestamp))
|
|
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error setting mtime for {file_path}: {e}")
|
|
|
|
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
|
"""Update modification times for all video files in movie directory"""
|
|
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
|
updated_files = []
|
|
|
|
for file_path in movie_dir.iterdir():
|
|
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
|
self.set_file_mtime(file_path, iso_timestamp)
|
|
updated_files.append(file_path.name)
|
|
|
|
if updated_files:
|
|
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
|
|
else:
|
|
print(f"⚠️ No video files found to update in {movie_dir.name}")
|
|
|
|
def strip_lockdata_from_nfo(self, nfo_path: Path) -> bool:
|
|
"""Strip existing lockdata elements from NFO file for Emby compatibility"""
|
|
if not nfo_path.exists():
|
|
return False
|
|
|
|
try:
|
|
tree = ET.parse(nfo_path)
|
|
root = tree.getroot()
|
|
|
|
# Find and remove all lockdata elements
|
|
lockdata_elements = root.findall('.//lockdata')
|
|
if not lockdata_elements:
|
|
return False # No lockdata found, nothing to remove
|
|
|
|
removed_count = 0
|
|
for lockdata_elem in lockdata_elements:
|
|
# Find parent and remove the element
|
|
for parent in root.iter():
|
|
if lockdata_elem in parent:
|
|
parent.remove(lockdata_elem)
|
|
removed_count += 1
|
|
break
|
|
|
|
if removed_count > 0:
|
|
# Write back the modified NFO
|
|
ET.indent(tree, space=" ", level=0)
|
|
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
|
|
_log("INFO", f"Removed {removed_count} lockdata elements from {nfo_path.name}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Error stripping lockdata from {nfo_path}: {e}")
|
|
|
|
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():
|
|
return 0
|
|
|
|
processed_count = 0
|
|
nfo_files = list(directory_path.glob("**/*.nfo"))
|
|
|
|
_log("INFO", f"Scanning {len(nfo_files)} NFO files for lockdata removal in {directory_path}")
|
|
|
|
for nfo_file in nfo_files:
|
|
if self.strip_lockdata_from_nfo(nfo_file):
|
|
processed_count += 1
|
|
|
|
_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,
|
|
imdb_id: Optional[str] = None, tv_processor=None) -> bool:
|
|
"""Update existing TV episode NFO file while preserving its original filename"""
|
|
if not nfo_path.exists():
|
|
return False
|
|
|
|
# If no episode data provided and we have IMDb ID, try to fetch it
|
|
if (not aired or not dateadded) and imdb_id and tv_processor:
|
|
print(f"⚠️ Missing episode data for S{season_num:02d}E{episode_num:02d}, attempting to fetch from APIs")
|
|
try:
|
|
fetched_aired, fetched_dateadded = tv_processor._get_episode_dates_from_apis(imdb_id, season_num, episode_num)
|
|
if fetched_aired and not aired:
|
|
aired = fetched_aired
|
|
print(f"✅ Fetched aired date: {aired}")
|
|
if fetched_dateadded and not dateadded:
|
|
dateadded = fetched_dateadded
|
|
print(f"✅ Fetched dateadded: {dateadded}")
|
|
except Exception as e:
|
|
print(f"⚠️ Failed to fetch episode data: {e}")
|
|
|
|
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 list(episode):
|
|
if hasattr(child, 'tag') and child.tag is ET.Comment:
|
|
if self.manager_brand in str(child):
|
|
episode.remove(child)
|
|
elif str(type(child)).find('Comment') != -1:
|
|
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
|
|
print(f"🔍 NFO Update Debug - aired: '{aired}', dateadded: '{dateadded}'")
|
|
if aired:
|
|
aired_elem = ET.SubElement(episode, "aired")
|
|
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
|
print(f"✅ Added aired field: {aired}")
|
|
else:
|
|
print(f"⚠️ No aired date provided")
|
|
|
|
if dateadded:
|
|
dateadded_elem = ET.SubElement(episode, "dateadded")
|
|
dateadded_elem.text = dateadded
|
|
print(f"✅ Added dateadded field: {dateadded}")
|
|
else:
|
|
print(f"⚠️ No dateadded provided")
|
|
|
|
# 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
|
|
|
|
def find_video_file_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
|
"""Find the video file for a specific episode to match NFO naming"""
|
|
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 = list(season_dir.glob(f"*{season_episode_pattern}*{ext}"))
|
|
if video_files:
|
|
# Return the first matching video file
|
|
return video_files[0]
|
|
|
|
return None
|
|
|