update to docker
This commit is contained in:
+36
-7
@@ -1,5 +1,27 @@
|
|||||||
# NFOGuard Project Overview
|
# NFOGuard Project Overview
|
||||||
|
|
||||||
|
## 🎯 Three-Component System Architecture
|
||||||
|
|
||||||
|
**NFOGuard** is a comprehensive media date management ecosystem consisting of three integrated repositories:
|
||||||
|
|
||||||
|
### 1. **NFOGuard (Docker Service)** [THIS REPOSITORY]
|
||||||
|
- **Purpose**: Main webhook service that listens for Sonarr/Radarr events and manages NFO files
|
||||||
|
- **Version**: v1.6.0
|
||||||
|
- **Technology**: Python 3, Docker, PostgreSQL integration
|
||||||
|
- **Role**: Records import dates, creates/updates NFO files, manages filesystem timestamps
|
||||||
|
|
||||||
|
### 2. **NFOGuard-Emby-Plugin** (Companion DLL)
|
||||||
|
- **Purpose**: Emby Server plugin that reads NFO dateadded values and syncs to Emby's DateCreated field
|
||||||
|
- **Version**: v0.6.1
|
||||||
|
- **Technology**: C# .NET Framework 4.8, Emby SDK
|
||||||
|
- **Role**: Real-time date synchronization within Emby, license validation
|
||||||
|
|
||||||
|
### 3. **nfoguard-license-server** (License Management)
|
||||||
|
- **Purpose**: License validation server with admin interface for plugin access control
|
||||||
|
- **Version**: v1.3.1
|
||||||
|
- **Technology**: Node.js, Express, SQLite
|
||||||
|
- **Role**: 7-day trials, license validation, user management dashboard
|
||||||
|
|
||||||
## Project Purpose
|
## Project Purpose
|
||||||
NFOGuard is an automated NFO file management system that integrates with Radarr and Sonarr to maintain accurate metadata and release dates in media library NFO files. It serves as a webhook-driven middleware that preserves existing metadata while adding clean, standardized date information.
|
NFOGuard is an automated NFO file management system that integrates with Radarr and Sonarr to maintain accurate metadata and release dates in media library NFO files. It serves as a webhook-driven middleware that preserves existing metadata while adding clean, standardized date information.
|
||||||
|
|
||||||
@@ -302,7 +324,7 @@ NFOGuard is an automated NFO file management system that integrates with Radarr
|
|||||||
### When Starting a New Session
|
### When Starting a New Session
|
||||||
1. **Read Project.md** - This file for full context
|
1. **Read Project.md** - This file for full context
|
||||||
2. **Check SUMMARY.md** - Latest changes and current version
|
2. **Check SUMMARY.md** - Latest changes and current version
|
||||||
3. **Review VERSION** - Current version number
|
3. **Review VERSION** - Current version number (v1.6.0)
|
||||||
4. **Key Files to Understand**:
|
4. **Key Files to Understand**:
|
||||||
- `nfoguard.py` - Main logic, webhook handlers
|
- `nfoguard.py` - Main logic, webhook handlers
|
||||||
- `core/nfo_manager.py` - NFO creation, parsing, renaming
|
- `core/nfo_manager.py` - NFO creation, parsing, renaming
|
||||||
@@ -315,17 +337,24 @@ NFOGuard is an automated NFO file management system that integrates with Radarr
|
|||||||
- **NFO Renaming**: `core/nfo_manager.py:336-491` (smart episode renaming)
|
- **NFO Renaming**: `core/nfo_manager.py:336-491` (smart episode renaming)
|
||||||
- **Dual Identification**: `core/nfo_manager.py:85-149` (directory + NFO parsing)
|
- **Dual Identification**: `core/nfo_manager.py:85-149` (directory + NFO parsing)
|
||||||
|
|
||||||
### User Context
|
### Project Context (Updated 2025-09-17)
|
||||||
- **User has**: Radarr, Sonarr, Unmanic integration
|
- **Current Status**: Production-ready system with 3-component architecture
|
||||||
- **Environment**: Docker deployment, PostgreSQL databases
|
- **Version**: v1.6.0 (NFOGuard Docker), v1.1.18 (Emby Plugin), v1.3.2 (License Server)
|
||||||
- **Focus Areas**: Webhook accuracy, NFO standardization, date integrity
|
- **User Environment**: Docker deployment, PostgreSQL databases, Unmanic integration
|
||||||
|
- **Focus Areas**: Webhook accuracy, NFO standardization, date integrity, lifetime license management
|
||||||
- **Testing Method**: Real webhooks from Radarr/Sonarr, manual scans for validation
|
- **Testing Method**: Real webhooks from Radarr/Sonarr, manual scans for validation
|
||||||
|
- **Recent Updates**: Fixed lifetime license display issue with explicit "never" expires handling
|
||||||
|
|
||||||
|
### Three-Repository System Overview
|
||||||
|
1. **NFOGuard (Docker)** - Main webhook service and NFO management
|
||||||
|
2. **NFOGuard-Emby-Plugin** - DLL for real-time Emby date synchronization
|
||||||
|
3. **nfoguard-license-server** - License validation and user management
|
||||||
|
|
||||||
### Recent Work Pattern
|
### Recent Work Pattern
|
||||||
- User reports issues with specific examples (log snippets, webhook behavior)
|
- User reports issues with specific examples (log snippets, webhook behavior)
|
||||||
- We debug by analyzing code at specific line numbers
|
- We debug by analyzing code at specific line numbers
|
||||||
- Implement fixes with proper error handling and logging
|
- Implement fixes with proper error handling and logging
|
||||||
- Update documentation (SUMMARY.md, README.md, Project.md)
|
- Update documentation (SUMMARY.md, README.md, Project.md) across all repositories
|
||||||
- Version bump and prepare for deployment
|
- Version bump and prepare for deployment
|
||||||
|
|
||||||
This project represents a mature, production-ready system for automated NFO management with comprehensive webhook integration and intelligent metadata handling.
|
This project represents a mature, production-ready system for automated NFO management with comprehensive webhook integration, intelligent metadata handling, and integrated license management.
|
||||||
@@ -14,14 +14,18 @@ from urllib.error import URLError, HTTPError
|
|||||||
from core.logging import _log
|
from core.logging import _log
|
||||||
|
|
||||||
|
|
||||||
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None) -> Optional[Dict[str, Any]]:
|
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
"""Make GET request and return JSON"""
|
"""Make GET request and return JSON"""
|
||||||
try:
|
try:
|
||||||
req = UrlRequest(url, headers=headers or {"Accept": "application/json"})
|
req = UrlRequest(url, headers=headers or {"Accept": "application/json"})
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("WARNING", f"GET {url} failed: {e}")
|
# Handle HTTP 400/404 errors more gracefully for APIs where missing data is normal
|
||||||
|
if suppress_404 and ("400" in str(e) or "404" in str(e)):
|
||||||
|
_log("DEBUG", f"GET {url} - item not found (expected): {e}")
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"GET {url} failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -83,16 +87,20 @@ class TVDBClient:
|
|||||||
url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
|
url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
data = _get_json(url, headers=headers)
|
data = _get_json(url, headers=headers, suppress_404=True)
|
||||||
if data and data.get("status") == "success" and data.get("data"):
|
if data and data.get("status") == "success" and data.get("data"):
|
||||||
series_list = data["data"]
|
series_list = data["data"]
|
||||||
if series_list and len(series_list) > 0:
|
if series_list and len(series_list) > 0:
|
||||||
tvdb_id = series_list[0].get("id")
|
tvdb_id = series_list[0].get("id")
|
||||||
if tvdb_id:
|
if tvdb_id:
|
||||||
_log("INFO", f"TVDB: Converted {imdb_id} → {tvdb_id}")
|
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id}")
|
||||||
return str(tvdb_id)
|
return str(tvdb_id)
|
||||||
|
|
||||||
|
# If we get here, the series wasn't found in TVDB (common for newer shows)
|
||||||
|
_log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("WARNING", f"TVDB IMDB conversion failed for {imdb_id}: {e}")
|
_log("WARNING", f"TVDB API error for {imdb_id}: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+53
-14
@@ -18,20 +18,35 @@ class NFOManager:
|
|||||||
self.manager_brand = manager_brand
|
self.manager_brand = manager_brand
|
||||||
|
|
||||||
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||||
"""Extract IMDb ID from directory path"""
|
"""Extract IMDb ID from directory path or filename"""
|
||||||
# Look for [imdb-ttXXXXXXX] or [ttXXXXXXX] patterns
|
# Look for various IMDb patterns in both directory and file names
|
||||||
path_str = str(path).lower()
|
path_str = str(path).lower()
|
||||||
|
|
||||||
# Try [imdb-ttXXXXXXX] format first
|
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||||
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
# Try standalone [ttXXXXXXX] format
|
# Try standalone [ttXXXXXXX] format in brackets
|
||||||
match = re.search(r'\[(tt\d+)\]', path_str)
|
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||||
|
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||||
|
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||||
|
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
||||||
@@ -68,7 +83,8 @@ class NFOManager:
|
|||||||
movie.remove(uniqueid)
|
movie.remove(uniqueid)
|
||||||
|
|
||||||
except (ET.ParseError, ValueError) as e:
|
except (ET.ParseError, ValueError) as e:
|
||||||
print(f"Warning: Could not parse existing NFO {nfo_path}, creating new: {e}")
|
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean NFO file to replace corrupted one")
|
||||||
movie = ET.Element("movie")
|
movie = ET.Element("movie")
|
||||||
else:
|
else:
|
||||||
# Create new NFO structure
|
# Create new NFO structure
|
||||||
@@ -123,8 +139,11 @@ class NFOManager:
|
|||||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(full_xml)
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
|
||||||
|
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating/updating movie NFO {nfo_path}: {e}")
|
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
||||||
"""Create or update tvshow.nfo file preserving existing content"""
|
"""Create or update tvshow.nfo file preserving existing content"""
|
||||||
@@ -153,7 +172,8 @@ class NFOManager:
|
|||||||
tvshow.remove(uniqueid)
|
tvshow.remove(uniqueid)
|
||||||
|
|
||||||
except (ET.ParseError, ValueError) as e:
|
except (ET.ParseError, ValueError) as e:
|
||||||
print(f"Warning: Could not parse existing tvshow NFO {nfo_path}, creating new: {e}")
|
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
|
||||||
tvshow = ET.Element("tvshow")
|
tvshow = ET.Element("tvshow")
|
||||||
else:
|
else:
|
||||||
# Create new NFO structure
|
# Create new NFO structure
|
||||||
@@ -191,8 +211,11 @@ class NFOManager:
|
|||||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(full_xml)
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
|
||||||
|
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating/updating tvshow NFO {nfo_path}: {e}")
|
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
||||||
"""Create or update season.nfo file preserving existing content"""
|
"""Create or update season.nfo file preserving existing content"""
|
||||||
@@ -218,7 +241,8 @@ class NFOManager:
|
|||||||
season.remove(existing)
|
season.remove(existing)
|
||||||
|
|
||||||
except (ET.ParseError, ValueError) as e:
|
except (ET.ParseError, ValueError) as e:
|
||||||
print(f"Warning: Could not parse existing season NFO {nfo_path}, creating new: {e}")
|
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean season.nfo file to replace corrupted one")
|
||||||
season = ET.Element("season")
|
season = ET.Element("season")
|
||||||
else:
|
else:
|
||||||
# Create new NFO structure
|
# Create new NFO structure
|
||||||
@@ -249,8 +273,11 @@ class NFOManager:
|
|||||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(full_xml)
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||||
|
print(f" Season: {season_number}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating/updating season NFO {nfo_path}: {e}")
|
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||||
@@ -283,7 +310,8 @@ class NFOManager:
|
|||||||
episode.remove(existing)
|
episode.remove(existing)
|
||||||
|
|
||||||
except (ET.ParseError, ValueError) as e:
|
except (ET.ParseError, ValueError) as e:
|
||||||
print(f"Warning: Could not parse existing episode NFO {nfo_path}, creating new: {e}")
|
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean episode NFO file to replace corrupted one")
|
||||||
episode = ET.Element("episodedetails")
|
episode = ET.Element("episodedetails")
|
||||||
else:
|
else:
|
||||||
# Create new NFO structure
|
# Create new NFO structure
|
||||||
@@ -343,8 +371,11 @@ class NFOManager:
|
|||||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(full_xml)
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||||
|
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating/updating episode NFO {nfo_path}: {e}")
|
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
|
def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
|
||||||
"""Set file modification time to match import date"""
|
"""Set file modification time to match import date"""
|
||||||
@@ -363,14 +394,22 @@ class NFOManager:
|
|||||||
|
|
||||||
# Set both access and modification times
|
# Set both access and modification times
|
||||||
os.utime(file_path, (timestamp, timestamp))
|
os.utime(file_path, (timestamp, timestamp))
|
||||||
|
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error setting mtime for {file_path}: {e}")
|
print(f"❌ Error setting mtime for {file_path}: {e}")
|
||||||
|
|
||||||
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
||||||
"""Update modification times for all video files in movie directory"""
|
"""Update modification times for all video files in movie directory"""
|
||||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||||
|
updated_files = []
|
||||||
|
|
||||||
for file_path in movie_dir.iterdir():
|
for file_path in movie_dir.iterdir():
|
||||||
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
||||||
self.set_file_mtime(file_path, iso_timestamp)
|
self.set_file_mtime(file_path, iso_timestamp)
|
||||||
|
updated_files.append(file_path.name)
|
||||||
|
|
||||||
|
if updated_files:
|
||||||
|
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ No video files found to update in {movie_dir.name}")
|
||||||
Reference in New Issue
Block a user