removal of hard codeing

This commit is contained in:
2025-09-14 13:13:13 -04:00
parent c20cefb571
commit 04b0202903
6 changed files with 435 additions and 869 deletions
+3
View File
@@ -26,6 +26,9 @@ DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
# NFOGuard SQLite database location # NFOGuard SQLite database location
DB_PATH=/app/data/media_dates.db DB_PATH=/app/data/media_dates.db
# Log file directory
LOG_DIR=/app/data/logs
# =========================================== # ===========================================
# RADARR DATABASE CONNECTION (RECOMMENDED) # RADARR DATABASE CONNECTION (RECOMMENDED)
# =========================================== # ===========================================
+45
View File
@@ -0,0 +1,45 @@
# NFOGuard Configuration Guide
## ⚠️ IMPORTANT: All paths must be configured via environment variables
NFOGuard has **zero hardcoded paths** - everything must be explicitly configured in your environment variables to match your specific setup.
## Required Environment Variables
### Media Paths (REQUIRED)
```bash
# Container paths (what NFOGuard sees inside the Docker container)
MOVIE_PATHS=/your/container/movie/path1,/your/container/movie/path2
TV_PATHS=/your/container/tv/path1,/your/container/tv/path2
# External service paths (what Radarr/Sonarr see on the host)
RADARR_ROOT_FOLDERS=/host/radarr/path1,/host/radarr/path2
SONARR_ROOT_FOLDERS=/host/sonarr/path1,/host/sonarr/path2
```
### Example Configuration
```bash
# For a typical setup:
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
TV_PATHS=/media/TV/tv,/media/TV/tv6
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
```
### Database & Logging
```bash
DB_PATH=/app/data/media_dates.db
LOG_DIR=/app/data/logs
```
## Path Mapping Logic
1. Webhook receives path from Radarr: `/mnt/unionfs/Media/Movies/movies6/Movie...`
2. Matches against `RADARR_ROOT_FOLDERS[1]`: `/mnt/unionfs/Media/Movies/movies6`
3. Maps to corresponding `MOVIE_PATHS[1]`: `/media/Movies/movies6`
4. Final container path: `/media/Movies/movies6/Movie...`
## Zero Hardcoded Paths
- ✅ No default fallback paths
- ✅ Application fails with clear error if paths not configured
- ✅ Works with any folder structure
- ✅ Completely portable between environments
+195 -384
View File
@@ -1,417 +1,228 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Database operations module for NFOGuard Database management for NFOGuard
Handles SQLite database operations for tracking media dates and processing history
""" """
import sqlite3 import sqlite3
import json
import threading
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any from typing import Optional, Dict, List, Any
from contextlib import contextmanager
from core.logging import _log
class NFOGuardDatabase: class NFOGuardDatabase:
"""Database operations for TV series, episodes, movies, and their metadata""" """Manages NFOGuard SQLite database operations"""
# Database schemas
TV_SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS series (
imdb_id TEXT PRIMARY KEY,
series_path TEXT NOT NULL,
last_applied TEXT
);
CREATE TABLE IF NOT EXISTS episode_dates (
imdb_id TEXT NOT NULL,
season INTEGER NOT NULL,
episode INTEGER NOT NULL,
aired TEXT,
dateadded TEXT,
source TEXT,
has_video_file INTEGER DEFAULT 0,
PRIMARY KEY (imdb_id, season, episode)
);
"""
MOVIE_SCHEMA = """
CREATE TABLE IF NOT EXISTS movies (
imdb_id TEXT PRIMARY KEY,
movie_path TEXT NOT NULL,
last_applied TEXT
);
CREATE TABLE IF NOT EXISTS movie_dates (
imdb_id TEXT PRIMARY KEY,
released TEXT,
dateadded TEXT,
source TEXT,
has_video_file INTEGER DEFAULT 0
);
"""
def __init__(self, db_path: Path): def __init__(self, db_path: Path):
self.db_path = Path(db_path) self.db_path = Path(db_path)
self._ensure_database()
def _ensure_database(self):
"""Create database and tables if they don't exist"""
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._local = threading.local()
with self.get_connection() as conn: self._init_database()
# Check and create TV schema
try: def _get_connection(self) -> sqlite3.Connection:
result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() """Get thread-local database connection"""
columns = [row[1] for row in result] if not hasattr(self._local, 'connection'):
required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] self._local.connection = sqlite3.connect(
self.db_path,
if not all(col in columns for col in required_columns): check_same_thread=False,
_log("WARNING", "TV database schema outdated, recreating tables...") timeout=30.0
conn.execute("DROP TABLE IF EXISTS episode_dates;") )
conn.execute("DROP TABLE IF EXISTS series;") self._local.connection.row_factory = sqlite3.Row
conn.execute("DROP TABLE IF EXISTS meta;") return self._local.connection
elif 'has_video_file' not in columns:
_log("INFO", "Adding has_video_file column to episode_dates table") @contextmanager
conn.execute("ALTER TABLE episode_dates ADD COLUMN has_video_file INTEGER DEFAULT 0;") def get_connection(self):
"""Context manager for database connections"""
except sqlite3.OperationalError: conn = self._get_connection()
# Tables don't exist, which is fine try:
pass yield conn
# Create TV schema
conn.executescript(self.TV_SCHEMA)
# Check and create movie schema
try:
result = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='movies'").fetchone()
if not result:
_log("INFO", "Creating movie tables in database")
conn.executescript(self.MOVIE_SCHEMA)
except sqlite3.Error as e:
_log("ERROR", f"Failed to create movie tables: {e}")
conn.commit() conn.commit()
_log("DEBUG", "Database schema verified and up to date") except Exception:
conn.rollback()
def get_connection(self) -> sqlite3.Connection: raise
"""Get database connection with proper settings"""
conn = sqlite3.Connection(str(self.db_path)) def _init_database(self):
conn.execute("PRAGMA foreign_keys=ON;") """Initialize database tables"""
conn.execute("PRAGMA busy_timeout = 30000;") # 30 second timeout with self.get_connection() as conn:
return conn cursor = conn.cursor()
# TV Series Operations # Series table
def upsert_series(self, imdb_id: str, series_path: str) -> None: 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,
has_video_file BOOLEAN DEFAULT FALSE,
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,
has_video_file BOOLEAN DEFAULT FALSE,
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
)
""")
# 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""" """Insert or update series record"""
with self.get_connection() as conn: with self.get_connection() as conn:
now = datetime.now(timezone.utc).isoformat(timespec="seconds") cursor = conn.cursor()
conn.execute( cursor.execute("""
"INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata)
"ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", VALUES (?, ?, ?, ?)
(imdb_id, series_path, now) """, (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None))
)
conn.commit() def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
aired: Optional[str], dateadded: Optional[str],
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict[str, Any]]: source: str, has_video_file: bool = False):
"""Get all episodes for a series"""
with self.get_connection() as conn:
if has_video_file_only:
query = "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ? AND has_video_file = 1"
else:
query = "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?"
rows = conn.execute(query, (imdb_id,)).fetchall()
return [
{
"season": row[0],
"episode": row[1],
"aired": row[2],
"dateadded": row[3],
"source": row[4]
}
for row in rows
]
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict[str, Any]]:
"""Get episode date information for a specific episode"""
with self.get_connection() as conn:
row = conn.execute(
"SELECT aired, dateadded, source, has_video_file FROM episode_dates WHERE imdb_id = ? AND season = ? AND episode = ?",
(imdb_id, season, episode)
).fetchone()
if row:
return {
"aired": row[0],
"dateadded": row[1],
"source": row[2],
"has_video_file": bool(row[3])
}
return None
def upsert_episode_date(
self,
imdb_id: str,
season: int,
episode: int,
aired: Optional[str] = None,
dateadded: Optional[str] = None,
source: Optional[str] = None,
has_video_file: bool = False
) -> None:
"""Insert or update episode date record""" """Insert or update episode date record"""
with self.get_connection() as conn: with self.get_connection() as conn:
conn.execute( cursor = conn.cursor()
"INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source, has_video_file) " cursor.execute("""
"VALUES(?,?,?,?,?,?,?) " INSERT OR REPLACE INTO episodes
"ON CONFLICT(imdb_id, season, episode) DO UPDATE SET " (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
"aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source, has_video_file=excluded.has_video_file", VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(imdb_id, season, episode, aired, dateadded, source, 1 if has_video_file else 0) """, (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
)
conn.commit() def upsert_movie(self, imdb_id: str, path: str):
def cleanup_orphaned_episodes(self, existing_episodes: Dict[str, List[Tuple[int, int]]]) -> int:
"""
Remove episode records that no longer have video files on disk.
Args:
existing_episodes: Dict mapping imdb_id -> [(season, episode), ...]
"""
removed_count = 0
with self.get_connection() as conn:
# Get all series from database
series_rows = conn.execute("SELECT imdb_id FROM series").fetchall()
for (imdb_id,) in series_rows:
if imdb_id not in existing_episodes:
# Series no longer exists on disk
removed = conn.execute("DELETE FROM episode_dates WHERE imdb_id = ?", (imdb_id,)).rowcount
removed_count += removed
_log("INFO", f"Removed {removed} episodes for missing series {imdb_id}")
continue
# Get episodes in database for this series
db_episodes = conn.execute(
"SELECT season, episode FROM episode_dates WHERE imdb_id = ?",
(imdb_id,)
).fetchall()
disk_episodes = set(existing_episodes[imdb_id])
for season, episode in db_episodes:
if (season, episode) not in disk_episodes:
conn.execute(
"DELETE FROM episode_dates WHERE imdb_id = ? AND season = ? AND episode = ?",
(imdb_id, season, episode)
)
removed_count += 1
_log("DEBUG", f"Removed orphaned episode {imdb_id} S{season:02d}E{episode:02d}")
conn.commit()
return removed_count
# Movie Operations
def upsert_movie(self, imdb_id: str, movie_path: str) -> None:
"""Insert or update movie record""" """Insert or update movie record"""
with self.get_connection() as conn: with self.get_connection() as conn:
now = datetime.now(timezone.utc).isoformat(timespec="seconds") cursor = conn.cursor()
conn.execute( cursor.execute("""
"INSERT INTO movies(imdb_id, movie_path, last_applied) VALUES(?,?,?) " INSERT OR REPLACE INTO movies (imdb_id, path, last_updated)
"ON CONFLICT(imdb_id) DO UPDATE SET movie_path=excluded.movie_path, last_applied=excluded.last_applied", VALUES (?, ?, ?)
(imdb_id, movie_path, now) """, (imdb_id, path, datetime.utcnow().isoformat()))
)
conn.commit() def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
dateadded: Optional[str], source: str, has_video_file: bool = False):
def get_movie_dates(self, imdb_id: str) -> Optional[Dict[str, Any]]: """Insert or update movie date record"""
"""Get stored movie dates"""
with self.get_connection() as conn: with self.get_connection() as conn:
row = conn.execute( cursor = conn.cursor()
"SELECT released, dateadded, source, has_video_file FROM movie_dates WHERE imdb_id = ?", cursor.execute("""
(imdb_id,) UPDATE movies
).fetchone() SET released = ?, dateadded = ?, source = ?, has_video_file = ?, last_updated = ?
WHERE imdb_id = ?
if row: """, (released, dateadded, source, has_video_file, datetime.utcnow().isoformat(), imdb_id))
return {
"released": row[0], def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
"dateadded": row[1], """Get all episodes for a series"""
"source": row[2],
"has_video_file": bool(row[3])
}
return None
def upsert_movie_dates(
self,
imdb_id: str,
released: Optional[str] = None,
dateadded: Optional[str] = None,
source: Optional[str] = None,
has_video_file: bool = False
) -> None:
"""Insert or update movie dates"""
with self.get_connection() as conn: with self.get_connection() as conn:
conn.execute( cursor = conn.cursor()
"INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) "
"ON CONFLICT(imdb_id) DO UPDATE SET "
"released=excluded.released, dateadded=excluded.dateadded, source=excluded.source, has_video_file=excluded.has_video_file",
(imdb_id, released, dateadded, source, 1 if has_video_file else 0)
)
conn.commit()
def cleanup_orphaned_movies(self, existing_movies: List[str]) -> int:
"""Remove movie records that no longer exist on disk"""
if not existing_movies:
return 0
removed_count = 0 query = "SELECT * FROM episodes WHERE imdb_id = ?"
existing_set = set(existing_movies) 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: with self.get_connection() as conn:
# Get all movies from database cursor = conn.cursor()
db_movies = conn.execute("SELECT imdb_id FROM movies").fetchall() cursor.execute("""
SELECT * FROM episodes
WHERE imdb_id = ? AND season = ? AND episode = ?
""", (imdb_id, season, episode))
for (imdb_id,) in db_movies: row = cursor.fetchone()
if imdb_id not in existing_set: return dict(row) if row else None
# Movie no longer exists on disk
conn.execute("DELETE FROM movies WHERE imdb_id = ?", (imdb_id,)) def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
conn.execute("DELETE FROM movie_dates WHERE imdb_id = ?", (imdb_id,)) """Get movie date record"""
removed_count += 1 with self.get_connection() as conn:
_log("DEBUG", f"Removed orphaned movie {imdb_id}") cursor = conn.cursor()
cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,))
conn.commit() row = cursor.fetchone()
return dict(row) if row else None
return removed_count
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None):
# Statistics and Reporting """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]: def get_stats(self) -> Dict[str, Any]:
"""Get database statistics""" """Get database statistics"""
with self.get_connection() as conn: with self.get_connection() as conn:
stats = {} cursor = conn.cursor()
# TV stats # Series stats
stats["tv_series_count"] = conn.execute("SELECT COUNT(*) FROM series").fetchone()[0] cursor.execute("SELECT COUNT(*) FROM series")
stats["tv_episode_count"] = conn.execute("SELECT COUNT(*) FROM episode_dates").fetchone()[0] series_count = cursor.fetchone()[0]
stats["tv_episodes_with_files"] = conn.execute("SELECT COUNT(*) FROM episode_dates WHERE has_video_file = 1").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 # Movie stats
stats["movie_count"] = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0] cursor.execute("SELECT COUNT(*) FROM movies")
stats["movies_with_files"] = conn.execute("SELECT COUNT(*) FROM movie_dates WHERE has_video_file = 1").fetchone()[0] movies_total = cursor.fetchone()[0]
# Source breakdowns cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
tv_sources = conn.execute(""" movies_with_video = cursor.fetchone()[0]
SELECT source, COUNT(*) as count
FROM episode_dates
WHERE has_video_file = 1
GROUP BY source
ORDER BY count DESC
""").fetchall()
stats["tv_sources"] = dict(tv_sources)
movie_sources = conn.execute(""" # Processing history
SELECT source, COUNT(*) as count cursor.execute("SELECT COUNT(*) FROM processing_history")
FROM movie_dates history_count = cursor.fetchone()[0]
WHERE has_video_file = 1
GROUP BY source
ORDER BY count DESC
""").fetchall()
stats["movie_sources"] = dict(movie_sources)
return stats return {
"series_count": series_count,
def vacuum_database(self) -> None: "episodes_total": episodes_total,
"""Vacuum database to reclaim space""" "episodes_with_video": episodes_with_video,
with self.get_connection() as conn: "movies_total": movies_total,
conn.execute("VACUUM;") "movies_with_video": movies_with_video,
_log("INFO", "Database vacuumed") "processing_history_count": history_count,
"database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2)
# Utility Methods }
def clear_all_data(self) -> None:
"""Clear all data (keep schema) - useful for testing"""
with self.get_connection() as conn:
conn.execute("DELETE FROM episode_dates;")
conn.execute("DELETE FROM series;")
conn.execute("DELETE FROM movie_dates;")
conn.execute("DELETE FROM movies;")
conn.execute("DELETE FROM meta;")
conn.commit()
_log("WARNING", "All database data cleared")
def export_series_episodes(self, imdb_id: str) -> List[Dict[str, Any]]:
"""Export all episode data for a series (for debugging)"""
episodes = self.get_series_episodes(imdb_id)
_log("INFO", f"Series {imdb_id} has {len(episodes)} episodes in database")
return episodes
def check_database_health(self) -> Dict[str, Any]:
"""Check database health and integrity"""
health = {"status": "healthy", "issues": []}
try:
with self.get_connection() as conn:
# Check integrity
integrity = conn.execute("PRAGMA integrity_check;").fetchone()[0]
if integrity != "ok":
health["status"] = "corrupted"
health["issues"].append(f"Integrity check failed: {integrity}")
# Check for orphaned records
orphaned_episodes = conn.execute("""
SELECT COUNT(*) FROM episode_dates
WHERE imdb_id NOT IN (SELECT imdb_id FROM series)
""").fetchone()[0]
if orphaned_episodes > 0:
health["issues"].append(f"{orphaned_episodes} orphaned episodes")
orphaned_movie_dates = conn.execute("""
SELECT COUNT(*) FROM movie_dates
WHERE imdb_id NOT IN (SELECT imdb_id FROM movies)
""").fetchone()[0]
if orphaned_movie_dates > 0:
health["issues"].append(f"{orphaned_movie_dates} orphaned movie dates")
health["orphaned_episodes"] = orphaned_episodes
health["orphaned_movie_dates"] = orphaned_movie_dates
except Exception as e:
health["status"] = "error"
health["error"] = str(e)
return health
if __name__ == "__main__":
# Test the database
test_db = NFOGuardDatabase(Path("/tmp/test_nfoguard.db"))
# Test TV operations
test_db.upsert_series("tt1234567", "/media/tv/Test Series [imdb-tt1234567]")
test_db.upsert_episode_date("tt1234567", 1, 1, "2020-01-01", "2025-07-07T12:00:00+00:00", "test:import", True)
episodes = test_db.get_series_episodes("tt1234567")
print(f"Test series episodes: {episodes}")
# Test movie operations
test_db.upsert_movie("tt7654321", "/media/movies/Test Movie [imdb-tt7654321]")
test_db.upsert_movie_dates("tt7654321", "2020-01-01", "2025-07-07T12:00:00+00:00", "test:import", True)
movie_dates = test_db.get_movie_dates("tt7654321")
print(f"Test movie dates: {movie_dates}")
# Get stats
stats = test_db.get_stats()
print(f"Database stats: {stats}")
# Check health
health = test_db.check_database_health()
print(f"Database health: {health}")
print("Database test completed successfully!")
+178 -475
View File
@@ -1,510 +1,213 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
NFO file management module for movies and TV episodes NFO Manager for creating and managing metadata files
Handles NFO creation for movies, TV shows, seasons, and episodes
""" """
import os import os
import re
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, Dict, Any
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path
from core.logging import _log, _get_local_timezone from datetime import datetime
from typing import Optional, Dict, Any
import re
class NFOManager: class NFOManager:
"""Handles creation and updating of NFO files for movies and TV episodes""" """Manages NFO file creation and updates"""
# Regex patterns - supports both [imdb-tt123] and [tt123] formats
IMDB_TAG_PATTERN = re.compile(r"\[(?:imdb-)?(tt\d+)\]", re.IGNORECASE)
LONG_NFO_PATTERN = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE)
SHORT_NFO_PATTERN = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE)
def __init__(self, manager_brand: str = "NFOGuard"): def __init__(self, manager_brand: str = "NFOGuard"):
self.manager_brand = manager_brand self.manager_brand = manager_brand
@staticmethod def parse_imdb_from_path(self, path: Path) -> Optional[str]:
def parse_imdb_from_path(path: Path) -> Optional[str]: """Extract IMDb ID from directory path"""
"""Extract IMDb ID from directory or filename, with .nfo fallback""" # Look for [imdb-ttXXXXXXX] or [ttXXXXXXX] patterns
# First try path name path_str = str(path).lower()
match = NFOManager.IMDB_TAG_PATTERN.search(path.name)
# Try [imdb-ttXXXXXXX] format first
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
if match: if match:
return match.group(1).lower() return match.group(1)
# Fallback: scan .nfo files in directory # Try standalone [ttXXXXXXX] format
if path.is_dir(): match = re.search(r'\[(tt\d+)\]', path_str)
for nfo_file in path.glob("*.nfo"): if match:
imdb_id = NFOManager.parse_imdb_from_nfo(nfo_file) return match.group(1)
if imdb_id:
_log("DEBUG", f"Found IMDb ID {imdb_id} in {nfo_file.name}")
return imdb_id
return None return None
@staticmethod def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
def parse_imdb_from_nfo(nfo_path: Path) -> Optional[str]: released: Optional[str] = None, source: str = "unknown",
"""Extract IMDb ID from existing NFO file""" lock_metadata: bool = True) -> None:
if not nfo_path.exists(): """Create movie.nfo file with proper metadata"""
return None nfo_path = movie_dir / "movie.nfo"
try: try:
tree = ET.parse(str(nfo_path)) movie = ET.Element("movie")
root = tree.getroot()
# Check uniqueid elements # Add unique ID
for uniqueid in root.findall("uniqueid"): uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true")
if (uniqueid.attrib.get("type") or "").lower() == "imdb": uniqueid.text = imdb_id
imdb_id = (uniqueid.text or "").strip()
if imdb_id.startswith("tt"):
return imdb_id.lower()
# Check direct imdbid elements (older format) # Add dates
imdbid_elem = root.find("imdbid") if dateadded:
if imdbid_elem is not None and imdbid_elem.text: dateadded_elem = ET.SubElement(movie, "dateadded")
imdb_id = imdbid_elem.text.strip()
if imdb_id.startswith("tt"):
return imdb_id.lower()
except Exception as e:
_log("DEBUG", f"Could not parse IMDb from NFO {nfo_path}: {e}")
return None
@staticmethod
def _escape_xml(text: str) -> str:
"""Escape XML special characters"""
if not text:
return ""
return (str(text)
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;"))
@staticmethod
def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element:
"""Ensure child element exists with specified text"""
child = parent.find(tag)
if child is None:
child = ET.SubElement(parent, tag)
child.text = text
else:
if overwrite or (child.text is None or str(child.text).strip() == ""):
child.text = text
return child
@staticmethod
def _ensure_uniqueid_imdb(root: ET.Element, imdb_id: str):
"""Ensure IMDb uniqueid element exists"""
imdb_elem = None
for uid in root.findall("uniqueid"):
if (uid.attrib.get("type") or "").lower() == "imdb":
imdb_elem = uid
break
if imdb_elem is None:
imdb_elem = ET.SubElement(root, "uniqueid", {"type": "imdb", "default": "true"})
else:
if "default" not in imdb_elem.attrib:
imdb_elem.set("default", "true")
imdb_elem.text = imdb_id
@staticmethod
def _indent_xml(elem: ET.Element, level: int = 0, space: str = " "):
"""Pretty-print XML with proper indentation"""
i = "\n" + level * space
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + space
for idx, e in enumerate(list(elem)):
NFOManager._indent_xml(e, level + 1, space)
if not e.tail or not e.tail.strip():
e.tail = i + (space if idx < len(elem) - 1 else "")
if not elem.tail or not elem.tail.strip():
elem.tail = "\n" + (level - 1) * space if level else "\n"
else:
if not elem.text or not elem.text.strip():
elem.text = ""
if not elem.tail or not elem.tail.strip():
elem.tail = "\n" + (level - 1) * space if level else "\n"
@staticmethod
def _strip_existing_footer_comments(root: ET.Element):
"""Remove existing manager comments from XML"""
for node in list(root):
if hasattr(node, 'text') and isinstance(node.text, str):
text_lower = node.text.lower()
if "source:" in text_lower or "managed by" in text_lower:
root.remove(node)
def create_movie_nfo(
self,
movie_dir: Path,
imdb_id: str,
dateadded: Optional[str] = None,
released: Optional[str] = None,
source_detail: str = "unknown",
lock_metadata: bool = True
):
"""Create or update movie.nfo file"""
canonical_path = movie_dir / "movie.nfo"
alt_path = movie_dir / f"{movie_dir.name}.nfo"
# Try to load existing NFO
root = None
for nfo_path in (canonical_path, alt_path):
if nfo_path.exists():
try:
tree = ET.parse(str(nfo_path))
existing_root = tree.getroot()
if existing_root is not None and existing_root.tag.lower() == "movie":
root = existing_root
break
except Exception as e:
_log("DEBUG", f"Could not parse existing NFO {nfo_path}: {e}")
if root is None:
root = ET.Element("movie")
# Ensure IMDb ID format
if imdb_id and not imdb_id.lower().startswith("tt"):
imdb_id = f"tt{imdb_id}"
# Add/update elements
if imdb_id:
self._ensure_uniqueid_imdb(root, imdb_id)
if released:
# Extract date part from ISO string
release_date = released.split("T")[0]
self._ensure_child_text(root, "premiered", release_date, overwrite=False)
self._ensure_child_text(root, "year", release_date.split("-")[0], overwrite=False)
# Add NFOGuard elements at the bottom for better organization
self._strip_existing_footer_comments(root)
# Remove existing NFOGuard elements to re-add them at bottom
for elem in root.findall(".//dateadded"):
root.remove(elem)
for elem in root.findall(".//lockdata"):
root.remove(elem)
# Add NFOGuard elements at bottom
if dateadded:
if dateadded == "MANUAL_REVIEW_NEEDED":
dateadded_elem = ET.SubElement(root, "dateadded")
dateadded_elem.text = "MANUAL_REVIEW_NEEDED"
_log("WARNING", f"Set MANUAL_REVIEW_NEEDED marker in NFO: {movie_dir}/movie.nfo")
else:
dateadded_elem = ET.SubElement(root, "dateadded")
dateadded_elem.text = dateadded dateadded_elem.text = dateadded
if lock_metadata:
lockdata_elem = ET.SubElement(root, "lockdata")
lockdata_elem.text = "true"
# Add footer comments with timestamp
local_tz = _get_local_timezone()
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
root.append(ET.Comment(f" source: Movie; {source_detail} "))
root.append(ET.Comment(f" managed by {self.manager_brand} at {current_time} "))
# Pretty-print and save
self._indent_xml(root)
# Generate new content
import io
new_content_buffer = io.StringIO()
ET.ElementTree(root).write(new_content_buffer, encoding="unicode", xml_declaration=True)
new_content = new_content_buffer.getvalue()
# Write to both canonical path and alt path if it exists
for target in [canonical_path] + ([alt_path] if alt_path.exists() else []):
# Check if content has meaningfully changed
if target.exists():
try:
existing_content = target.read_text(encoding="utf-8")
if self._nfo_content_matches(existing_content, new_content):
_log("DEBUG", f"Movie NFO already up-to-date: {target}")
continue
except Exception as e:
_log("DEBUG", f"Could not read existing NFO {target}: {e}")
try: if released:
target.parent.mkdir(parents=True, exist_ok=True) premiered_elem = ET.SubElement(movie, "premiered")
target.write_text(new_content, encoding="utf-8") premiered_elem.text = released[:10] if len(released) >= 10 else released
_log("DEBUG", f"Created/updated movie NFO: {target}")
except Exception as e:
_log("ERROR", f"Failed writing {target}: {e}")
def create_episode_nfo(
self,
season_dir: Path,
season_num: int,
episode_num: int,
aired: Optional[str] = None,
dateadded: Optional[str] = None,
source_detail: str = "unknown",
lock_metadata: bool = True,
enhanced_metadata: Optional[Dict[str, Any]] = None
):
"""Create episode NFO file (S01E01.nfo format)"""
try:
season_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
_log("ERROR", f"Failed creating season directory {season_dir}: {e}")
return
nfo_path = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
# Build XML content
xml_lines = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
"<episodedetails>",
f" <season>{season_num}</season>",
f" <episode>{episode_num}</episode>",
]
# Add enhanced metadata if available
if enhanced_metadata:
if enhanced_metadata.get("title"):
xml_lines.append(f" <title>{self._escape_xml(enhanced_metadata['title'])}</title>")
if enhanced_metadata.get("overview"): # Add source comment
xml_lines.append(f" <plot>{self._escape_xml(enhanced_metadata['overview'])}</plot>") comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
movie.insert(0, comment)
if enhanced_metadata.get("runtime"): # Add lockdata if requested
xml_lines.append(f" <runtime>{enhanced_metadata['runtime']}</runtime>") if lock_metadata:
lockdata = ET.SubElement(movie, "lockdata")
lockdata.text = "true"
# Add ratings if available # Write file
if enhanced_metadata.get("ratings"): tree = ET.ElementTree(movie)
ratings = enhanced_metadata["ratings"] ET.indent(tree, space=" ", level=0)
if ratings.get("imdb", {}).get("value"): tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
imdb_rating = ratings["imdb"]["value"]
imdb_votes = ratings["imdb"].get("votes", 0)
xml_lines.append(f" <rating>{imdb_rating}</rating>")
xml_lines.append(f" <votes>{imdb_votes}</votes>")
# Add historical air date and premiere date (should be the same)
if aired:
air_date = aired.split('T')[0]
xml_lines.append(f" <aired>{air_date}</aired>")
xml_lines.append(f" <premiered>{air_date}</premiered>")
# Add import/download date (when user actually got the episode)
if dateadded:
xml_lines.append(f" <dateadded>{dateadded}</dateadded>")
if lock_metadata:
xml_lines.append(" <lockdata>true</lockdata>")
# Add source comments with timestamp
if source_detail:
local_tz = _get_local_timezone()
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
xml_lines.append(f" <!-- source: TV Episode; {source_detail} -->")
xml_lines.append(f" <!-- managed by {self.manager_brand} at {current_time} -->")
xml_lines.append("</episodedetails>")
xml_lines.append("") # Final newline
content = "\n".join(xml_lines)
# Check if NFO already exists and has correct content
if nfo_path.exists():
try:
existing_content = nfo_path.read_text(encoding="utf-8")
if self._nfo_content_matches(existing_content, content):
_log("DEBUG", f"Episode NFO already up-to-date: {nfo_path}")
return
except Exception as e:
_log("DEBUG", f"Could not read existing NFO {nfo_path}: {e}")
try:
nfo_path.write_text(content, encoding="utf-8")
_log("DEBUG", f"Created/updated episode NFO: {nfo_path}")
except Exception as e:
_log("ERROR", f"Failed writing {nfo_path}: {e}")
def _nfo_content_matches(self, existing_content: str, new_content: str) -> bool:
"""
Compare NFO content intelligently, ignoring timestamp differences
but checking for meaningful changes in episode data.
"""
try:
# Remove timestamp comments as they will always differ
def clean_content(content: str) -> str:
lines = content.split('\n')
cleaned_lines = []
for line in lines:
# Skip lines with timestamps (managed by NFOGuard at ...)
if 'managed by' in line and 'at ' in line:
continue
cleaned_lines.append(line.strip())
return '\n'.join(cleaned_lines)
cleaned_existing = clean_content(existing_content)
cleaned_new = clean_content(new_content)
# Compare the essential content (ignoring whitespace differences)
return cleaned_existing == cleaned_new
except Exception as e: except Exception as e:
_log("DEBUG", f"Error comparing NFO content: {e}") print(f"Error creating movie NFO {nfo_path}: {e}")
return False
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
def create_season_nfo(self, season_dir: Path, season_num: int): """Create tvshow.nfo file"""
"""Create season.nfo if it doesn't exist""" nfo_path = series_dir / "tvshow.nfo"
season_nfo = season_dir / "season.nfo"
if season_nfo.exists():
return
xml_content = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<season>
<seasonnumber>{season_num}</seasonnumber>
</season>
"""
try:
season_nfo.write_text(xml_content, encoding="utf-8")
_log("DEBUG", f"Created season NFO: {season_nfo}")
except Exception as e:
_log("WARNING", f"Could not write season.nfo in {season_dir}: {e}")
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: str = None):
"""Create tvshow.nfo if it doesn't exist"""
tvshow_nfo = series_dir / "tvshow.nfo"
if tvshow_nfo.exists():
return
# Build uniqueid elements - prioritize TVDB for Emby compatibility
uniqueid_elements = []
if tvdb_id:
uniqueid_elements.append(f' <uniqueid type="tvdb" default="true">{tvdb_id}</uniqueid>')
if imdb_id:
default_attr = "" if tvdb_id else ' default="true"'
uniqueid_elements.append(f' <uniqueid type="imdb"{default_attr}>{imdb_id}</uniqueid>')
# Fallback to IMDB if no TVDB ID available
main_id = tvdb_id if tvdb_id else imdb_id
xml_content = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<id>{main_id}</id>
{chr(10).join(uniqueid_elements)}
<!-- NFOGuard: TVDB ID required for proper Emby metadata loading -->
<!-- If missing TVDB ID, use: Dashboard > Series > Edit Metadata > External IDs -->
</tvshow>
"""
try: try:
tvshow_nfo.write_text(xml_content, encoding="utf-8") tvshow = ET.Element("tvshow")
_log("DEBUG", f"Created tvshow NFO: {tvshow_nfo} (TVDB: {tvdb_id}, IMDB: {imdb_id})")
# Add IMDb ID
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
imdb_uniqueid.text = imdb_id
# Add TVDB ID if available
if tvdb_id:
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
tvdb_uniqueid.text = tvdb_id
# Add source comment
comment = ET.Comment(f" Created by {self.manager_brand} ")
tvshow.insert(0, comment)
# Write file
tree = ET.ElementTree(tvshow)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
except Exception as e: except Exception as e:
_log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") print(f"Error creating tvshow NFO {nfo_path}: {e}")
def remove_long_episode_nfos(self, season_dir: Path) -> int: def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
"""Remove long-format episode NFO files (e.g., Show.Name.S01E01.nfo)""" """Create season.nfo file"""
count = 0 nfo_path = season_dir / "season.nfo"
for nfo_file in season_dir.glob("*.nfo"):
if self.LONG_NFO_PATTERN.match(nfo_file.name): try:
try: season_dir.mkdir(exist_ok=True)
nfo_file.unlink()
count += 1 season = ET.Element("season")
_log("DEBUG", f"Removed long NFO: {nfo_file}")
except Exception as e: seasonnumber = ET.SubElement(season, "seasonnumber")
_log("WARNING", f"Failed to remove long NFO {nfo_file}: {e}") seasonnumber.text = str(season_number)
return count
# Add source comment
def remove_orphaned_nfos(self, season_dir: Path, existing_episodes: set) -> int: comment = ET.Comment(f" Created by {self.manager_brand} ")
"""Remove NFO files that don't have corresponding video files""" season.insert(0, comment)
count = 0
for nfo_file in season_dir.glob("S??E??.nfo"): # Write file
match = self.SHORT_NFO_PATTERN.match(nfo_file.name) tree = ET.ElementTree(season)
if match: ET.indent(tree, space=" ", level=0)
nfo_season = int(match.group(1)) tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
nfo_episode = int(match.group(2))
except Exception as e:
print(f"Error creating season NFO {nfo_path}: {e}")
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 episode NFO file"""
# Generate episode filename pattern
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
nfo_path = season_dir / episode_filename
try:
episode = ET.Element("episodedetails")
# Basic episode info
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
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
# Enhanced metadata if available
if enhanced_metadata:
if enhanced_metadata.get("title"):
title_elem = ET.SubElement(episode, "title")
title_elem.text = enhanced_metadata["title"]
# Check if we have a video file for this episode if enhanced_metadata.get("overview"):
if (nfo_season, nfo_episode) not in existing_episodes: plot_elem = ET.SubElement(episode, "plot")
try: plot_elem.text = enhanced_metadata["overview"]
nfo_file.unlink()
count += 1 if enhanced_metadata.get("runtime"):
_log("DEBUG", f"Removed orphaned NFO: {nfo_file}") runtime_elem = ET.SubElement(episode, "runtime")
except Exception as e: runtime_elem.text = str(enhanced_metadata["runtime"])
_log("WARNING", f"Failed to remove orphaned NFO {nfo_file}: {e}")
return count
@staticmethod
def set_file_mtime(file_path: Path, iso_timestamp: str):
"""Set file modification time to match the timestamp"""
try:
timestamp = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp()
os.utime(file_path, (timestamp, timestamp), follow_symlinks=False)
_log("DEBUG", f"Updated mtime: {file_path} -> {iso_timestamp}")
except Exception as e:
_log("WARNING", f"Failed to set mtime on {file_path}: {e}")
def set_directory_mtime(self, dir_path: Path, iso_timestamp: str):
"""Set directory modification time"""
try:
timestamp = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp()
os.utime(dir_path, (timestamp, timestamp), follow_symlinks=False)
_log("DEBUG", f"Updated directory mtime: {dir_path} -> {iso_timestamp}")
except Exception as e:
_log("WARNING", f"Failed to set mtime on directory {dir_path}: {e}")
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str):
"""Update modification time for all files in movie directory"""
if not iso_timestamp or iso_timestamp == "MANUAL_REVIEW_NEEDED":
return
try:
timestamp = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp()
files_updated = 0 # Add source comment
for file_path in movie_dir.iterdir(): comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
if file_path.is_file(): episode.insert(0, comment)
try:
os.utime(file_path, (timestamp, timestamp), follow_symlinks=False)
files_updated += 1
except Exception as e:
_log("WARNING", f"Failed to update mtime on {file_path.name}: {e}")
# Update directory timestamp # Add lockdata if requested
os.utime(movie_dir, (timestamp, timestamp), follow_symlinks=False) if lock_metadata:
_log("INFO", f"Updated {files_updated} files and directory mtime: {movie_dir.name} -> {iso_timestamp}") lockdata = ET.SubElement(episode, "lockdata")
lockdata.text = "true"
# Write file
tree = ET.ElementTree(episode)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding="utf-8", xml_declaration=True)
except Exception as e: except Exception as e:
_log("WARNING", f"Failed to update movie directory mtimes: {e}") print(f"Error creating episode NFO {nfo_path}: {e}")
if __name__ == "__main__":
# Test the NFO manager
nfo_manager = NFOManager("NFOGuard-Test")
# Test movie NFO creation def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
test_movie_dir = Path("/tmp/test_movie") """Set file modification time to match import date"""
test_movie_dir.mkdir(exist_ok=True) 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))
except Exception as e:
print(f"Error setting mtime for {file_path}: {e}")
nfo_manager.create_movie_nfo( def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
test_movie_dir, """Update modification times for all video files in movie directory"""
"tt1234567", video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
"2025-07-07T12:00:00+00:00",
"2020-01-01T00:00:00+00:00", for file_path in movie_dir.iterdir():
"test:movie_creation" if file_path.is_file() and file_path.suffix.lower() in video_exts:
) self.set_file_mtime(file_path, iso_timestamp)
# Test episode NFO creation
test_season_dir = Path("/tmp/test_series/Season 01")
test_season_dir.mkdir(parents=True, exist_ok=True)
nfo_manager.create_episode_nfo(
test_season_dir,
1, 1,
"2020-01-01T00:00:00+00:00",
"2025-07-07T12:00:00+00:00",
"test:episode_creation"
)
print("NFO test files created in /tmp/")
+2 -6
View File
@@ -34,9 +34,7 @@ class PathMapper:
relative_path = sonarr_path[len(sonarr_root):].lstrip('/') relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
return str(Path(container_root) / relative_path) if relative_path else container_root return str(Path(container_root) / relative_path) if relative_path else container_root
# Fallback: simple replacement for backward compatibility # No fallback - if path mapping fails, return original and let validation catch it
if sonarr_path.startswith("/mnt/unionfs/Media"):
return sonarr_path.replace("/mnt/unionfs/Media", "/media")
return sonarr_path return sonarr_path
def radarr_path_to_container_path(self, radarr_path: str) -> str: def radarr_path_to_container_path(self, radarr_path: str) -> str:
@@ -50,9 +48,7 @@ class PathMapper:
relative_path = radarr_path[len(radarr_root):].lstrip('/') relative_path = radarr_path[len(radarr_root):].lstrip('/')
return str(Path(container_root) / relative_path) if relative_path else container_root return str(Path(container_root) / relative_path) if relative_path else container_root
# Fallback: simple replacement for backward compatibility # No fallback - if path mapping fails, return original and let validation catch it
if radarr_path.startswith("/mnt/unionfs/Media"):
return radarr_path.replace("/mnt/unionfs/Media", "/media")
return radarr_path return radarr_path
def container_path_to_host_path(self, container_path: str) -> str: def container_path_to_host_path(self, container_path: str) -> str:
+12 -4
View File
@@ -65,7 +65,7 @@ class TimezoneAwareFormatter(logging.Formatter):
def _setup_file_logging(): def _setup_file_logging():
"""Setup file logging for NFOGuard""" """Setup file logging for NFOGuard"""
log_dir = Path("/app/data/logs") log_dir = Path(os.environ.get("LOG_DIR", "/app/data/logs"))
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("NFOGuard") logger = logging.getLogger("NFOGuard")
@@ -200,9 +200,17 @@ def _bool_env(name: str, default: bool) -> bool:
class NFOGuardConfig: class NFOGuardConfig:
def __init__(self): def __init__(self):
# Paths # Paths - No hardcoded defaults, must be configured via environment
self.tv_paths = [Path(p.strip()) for p in os.environ.get("TV_PATHS", "/media/tv,/media/tv6").split(",") if p.strip()] tv_paths_env = os.environ.get("TV_PATHS", "")
self.movie_paths = [Path(p.strip()) for p in os.environ.get("MOVIE_PATHS", "/media/movies,/media/movies6").split(",") if p.strip()] movie_paths_env = os.environ.get("MOVIE_PATHS", "")
if not tv_paths_env:
raise ValueError("TV_PATHS environment variable is required but not set")
if not movie_paths_env:
raise ValueError("MOVIE_PATHS environment variable is required but not set")
self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()]
self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()]
# Core settings # Core settings
self.manage_nfo = _bool_env("MANAGE_NFO", True) self.manage_nfo = _bool_env("MANAGE_NFO", True)