removal of hard codeing
This commit is contained in:
@@ -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)
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace("'", "'"))
|
|
||||||
|
|
||||||
@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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user