initial updates to mirror github files
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database management for NFOGuard
|
||||
Handles SQLite database operations for tracking media dates and processing history
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
from contextlib import contextmanager
|
||||
|
||||
class NFOGuardDatabase:
|
||||
"""Manages NFOGuard SQLite database operations"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._local = threading.local()
|
||||
self._init_database()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get thread-local database connection"""
|
||||
if not hasattr(self._local, 'connection'):
|
||||
self._local.connection = sqlite3.connect(
|
||||
self.db_path,
|
||||
check_same_thread=False,
|
||||
timeout=30.0
|
||||
)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
return self._local.connection
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
"""Context manager for database connections"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def _init_database(self):
|
||||
"""Initialize database tables with migration support"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Series table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
last_updated TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Episodes table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
imdb_id TEXT NOT NULL,
|
||||
season INTEGER NOT NULL,
|
||||
episode INTEGER NOT NULL,
|
||||
aired TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
last_updated TEXT NOT NULL,
|
||||
PRIMARY KEY (imdb_id, season, episode),
|
||||
FOREIGN KEY (imdb_id) REFERENCES series(imdb_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Movies table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
released TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
last_updated TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Processing history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imdb_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL,
|
||||
details TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Add missing columns if they don't exist (migration)
|
||||
# Check current schema and add missing columns
|
||||
cursor.execute("PRAGMA table_info(movies)")
|
||||
movie_columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute("PRAGMA table_info(episodes)")
|
||||
episode_columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
# Add missing columns to movies table
|
||||
if 'path' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT")
|
||||
cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL")
|
||||
|
||||
if 'has_video_file' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||
|
||||
if 'last_updated' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT")
|
||||
cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||
|
||||
# Add missing columns to episodes table
|
||||
if 'has_video_file' not in episode_columns:
|
||||
cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||
|
||||
if 'last_updated' not in episode_columns:
|
||||
cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT")
|
||||
cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||
|
||||
# Create indexes
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
|
||||
|
||||
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||
"""Insert or update series record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None))
|
||||
|
||||
def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
|
||||
aired: Optional[str], dateadded: Optional[str],
|
||||
source: str, has_video_file: bool = False):
|
||||
"""Insert or update episode date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO episodes
|
||||
(imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||
|
||||
def upsert_movie(self, imdb_id: str, path: str):
|
||||
"""Insert or update movie record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, path, last_updated)
|
||||
VALUES (?, ?, ?)
|
||||
""", (imdb_id, path, datetime.utcnow().isoformat()))
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no column named path" in str(e):
|
||||
# Fallback for databases without path column - just insert imdb_id
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, last_updated)
|
||||
VALUES (?, ?)
|
||||
""", (imdb_id, datetime.utcnow().isoformat()))
|
||||
else:
|
||||
raise
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False):
|
||||
"""Insert or update movie date record"""
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Use INSERT OR REPLACE to ensure we always update the dates properly
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (
|
||||
?,
|
||||
COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'),
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
""", (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]:
|
||||
"""Get all episodes for a series"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM episodes WHERE imdb_id = ?"
|
||||
params = [imdb_id]
|
||||
|
||||
if has_video_file_only:
|
||||
query += " AND has_video_file = TRUE"
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
|
||||
"""Get episode date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM episodes
|
||||
WHERE imdb_id = ? AND season = ? AND episode = ?
|
||||
""", (imdb_id, season, episode))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""Get movie date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None):
|
||||
"""Add processing history entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
|
||||
json.dumps(details) if details else None))
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Series stats
|
||||
cursor.execute("SELECT COUNT(*) FROM series")
|
||||
series_count = cursor.fetchone()[0]
|
||||
|
||||
# Episode stats
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes")
|
||||
episodes_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
|
||||
episodes_with_video = cursor.fetchone()[0]
|
||||
|
||||
# Movie stats
|
||||
cursor.execute("SELECT COUNT(*) FROM movies")
|
||||
movies_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
|
||||
movies_with_video = cursor.fetchone()[0]
|
||||
|
||||
# Processing history
|
||||
cursor.execute("SELECT COUNT(*) FROM processing_history")
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
"series_count": series_count,
|
||||
"episodes_total": episodes_total,
|
||||
"episodes_with_video": episodes_with_video,
|
||||
"movies_total": movies_total,
|
||||
"movies_with_video": movies_with_video,
|
||||
"processing_history_count": history_count,
|
||||
"database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Logging utilities for NFOguard"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
|
||||
def _get_local_timezone():
|
||||
"""Get the local timezone, respecting TZ environment variable"""
|
||||
tz_name = os.environ.get('TZ', 'UTC')
|
||||
|
||||
try:
|
||||
# Try zoneinfo first (Python 3.9+)
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(tz_name)
|
||||
except ImportError:
|
||||
# Fallback for older Python versions
|
||||
try:
|
||||
import pytz
|
||||
return pytz.timezone(tz_name)
|
||||
except:
|
||||
# Final fallback to UTC
|
||||
return timezone.utc
|
||||
except:
|
||||
# If zone name is invalid, fallback to UTC
|
||||
return timezone.utc
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Basic logging function that writes to console"""
|
||||
tz = _get_local_timezone()
|
||||
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}")
|
||||
|
||||
def convert_utc_to_local(utc_iso_string: str) -> str:
|
||||
"""Convert UTC ISO timestamp to local timezone timestamp"""
|
||||
if not utc_iso_string:
|
||||
return utc_iso_string
|
||||
|
||||
try:
|
||||
# Parse UTC timestamp
|
||||
if utc_iso_string.endswith('Z'):
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
|
||||
elif '+00:00' in utc_iso_string:
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string)
|
||||
else:
|
||||
# Assume UTC if no timezone info
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Convert to local timezone
|
||||
local_tz = _get_local_timezone()
|
||||
dt_local = dt_utc.astimezone(local_tz)
|
||||
|
||||
return dt_local.isoformat(timespec='seconds')
|
||||
except Exception:
|
||||
# If conversion fails, return original
|
||||
return utc_iso_string
|
||||
@@ -0,0 +1,565 @@
|
||||
#!/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
|
||||
|
||||
|
||||
class NFOManager:
|
||||
"""Manages NFO file creation and updates"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard"):
|
||||
self.manager_brand = manager_brand
|
||||
|
||||
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:
|
||||
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,
|
||||
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"
|
||||
|
||||
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", "lockdata", "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:
|
||||
released = existing.text # Preserve existing premiered date
|
||||
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 and date fields at the VERY END of the file
|
||||
# This ensures they appear after all existing content including actors
|
||||
|
||||
# Add IMDb uniqueid at the end (after all existing content)
|
||||
uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true")
|
||||
uniqueid.text = imdb_id
|
||||
|
||||
# Add premiered date at the bottom if we have it
|
||||
if released:
|
||||
premiered_elem = ET.SubElement(movie, "premiered")
|
||||
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
||||
|
||||
# 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.SubElement(movie, "year")
|
||||
year_elem.text = year_value
|
||||
except:
|
||||
pass # Skip year if we can't extract it
|
||||
|
||||
# Add dateadded at the end
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(movie, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(movie, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(movie)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
import xml.etree.ElementTree as ET_temp
|
||||
xml_str = ET.tostring(movie, 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 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
|
||||
|
||||
# Add lockdata at the very end
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# 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", "lockdata"]:
|
||||
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)
|
||||
|
||||
# Add lockdata at the end
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# 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 preserving existing content"""
|
||||
# Generate episode filename pattern
|
||||
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
nfo_path = season_dir / episode_filename
|
||||
|
||||
# Track if we need to delete an old long-named NFO file
|
||||
old_nfo_to_delete = None
|
||||
|
||||
try:
|
||||
# First, check for existing long-named NFO files that need migration
|
||||
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:
|
||||
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>")
|
||||
|
||||
# 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
|
||||
# These will be re-added at the bottom
|
||||
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
|
||||
for tag in nfoguard_fields:
|
||||
existing = episode.find(tag)
|
||||
if existing is not None:
|
||||
# Store the aired value before removing
|
||||
if tag == "aired" and not aired:
|
||||
aired = existing.text # Preserve existing aired date
|
||||
episode.remove(existing)
|
||||
|
||||
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")
|
||||
|
||||
# Enhanced metadata should be preserved - only add if not already present
|
||||
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"])
|
||||
|
||||
# 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 lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(episode, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(episode)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(episode, 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 episode NFO: {nfo_path}")
|
||||
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:
|
||||
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}")
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Path mapping utilities for NFOGuard
|
||||
Handles conversion between external service paths and container paths
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
class PathMapper:
|
||||
"""Handles path mapping between different environments"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize path mapper with configuration"""
|
||||
# Use environment variables directly since config attribute names are unclear
|
||||
import os
|
||||
|
||||
radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '')
|
||||
sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '')
|
||||
movie_paths_str = os.getenv('MOVIE_PATHS', '')
|
||||
tv_paths_str = os.getenv('TV_PATHS', '')
|
||||
|
||||
self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()]
|
||||
self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()]
|
||||
self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()]
|
||||
self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()]
|
||||
|
||||
# Check if path debugging is enabled
|
||||
self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true'
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: PathMapper initialized with:")
|
||||
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||
|
||||
def sonarr_path_to_container_path(self, sonarr_path: str) -> str:
|
||||
"""Convert Sonarr path to container path using environment mappings"""
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}")
|
||||
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||
|
||||
# Sort roots by length (longest first) to avoid substring matching issues
|
||||
indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)]
|
||||
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
# Try to match against configured Sonarr root folders (longest first)
|
||||
for original_index, sonarr_root in indexed_roots:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}")
|
||||
if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||
# Map to corresponding TV path
|
||||
if original_index < len(self.tv_paths):
|
||||
container_root = self.tv_paths[original_index]
|
||||
relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
|
||||
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||
return result
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}")
|
||||
# No fallback - if path mapping fails, return original and let validation catch it
|
||||
return sonarr_path
|
||||
|
||||
def radarr_path_to_container_path(self, radarr_path: str) -> str:
|
||||
"""Convert Radarr path to container path using environment mappings"""
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}")
|
||||
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||
|
||||
# Sort roots by length (longest first) to avoid substring matching issues
|
||||
indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)]
|
||||
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
# Try to match against configured Radarr root folders (longest first)
|
||||
for original_index, radarr_root in indexed_roots:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}")
|
||||
if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||
# Map to corresponding movie path
|
||||
if original_index < len(self.movie_paths):
|
||||
container_root = self.movie_paths[original_index]
|
||||
relative_path = radarr_path[len(radarr_root):].lstrip('/')
|
||||
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||
return result
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: No match found, returning original: {radarr_path}")
|
||||
# No fallback - if path mapping fails, return original and let validation catch it
|
||||
return radarr_path
|
||||
|
||||
def container_path_to_host_path(self, container_path: str) -> str:
|
||||
"""Convert container path back to host path if needed"""
|
||||
# This might be needed for file operations
|
||||
return container_path
|
||||
Reference in New Issue
Block a user