feat: intial commits

This commit is contained in:
2025-09-07 11:15:19 -04:00
committed by sbcrumb
parent 51b263ffe7
commit 5782b3970d
3 changed files with 166 additions and 47 deletions
+41 -30
View File
@@ -1,41 +1,52 @@
# Sonarr # =================================
# NFOGuard - Simple Configuration
# =================================
# --- Required: Media Library Paths ---
# Comma-separated paths to your media libraries
TV_PATHS=/media/TV/tv, /media/TV/tv6
MOVIE_PATHS=/media/Movies/movies, /media/Movies/movies6
# --- Required: Sonarr/Radarr Connection ---
SONARR_URL=http://sonarr:8989 SONARR_URL=http://sonarr:8989
SONARR_API_KEY= SONARR_API_KEY=
SONARR_TIMEOUT=45
SONARR_RETRIES=3
RADARR_URL=http://radarr:7878 RADARR_URL=http://radarr:7878
RADARR_API_KEY= RADARR_API_KEY=
RADARR_TIMEOUT=45
RADARR_RETRIES=3
# --- Required: TMDB for Release Dates ---
# Get free API key from https://www.themoviedb.org/settings/api
TMDB_API_KEY=
TMDB_COUNTRY=US
# Gotify (optional) # --- Behavior Settings ---
GOTIFY_URL= # When to update existing dates: always, missing_only, never
GOTIFY_TOKEN=-s UPDATE_MODE=always
# Update file modification times to match chosen date: update, leave_alone
MTIME_BEHAVIOR=update
#TMDB_API_KEY= # Lock metadata in NFO files to prevent overwrites: true, false
#TMDB_PRIMARY_COUNTRY=US
#TMDB_TIMEOUT=45
#TMDB_RETRIES=3
#OMDB_API_KEY=
TVDB_API_KEY=
# Batching configuration
BATCH_DELAY=5.0 # Seconds to wait for more webhooks
MAX_CONCURRENT_SERIES=3 # Max series proceossing simultaneously
TV_PATHS=/media/TV/tv,/media/TV/tv6
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
# Batching (optional)
#BATCH_DELAY=5.0
#MAX_CONCURRENT_SERIES=3
# Features (optional)
MANAGE_NFO=true
FIX_DIR_MTIMES=true
LOCK_METADATA=true LOCK_METADATA=true
# Brand name in NFO comments
MANAGER_BRAND=NFOGuard
# --- Optional: Path Mapping for Containers ---
# If your Radarr/Sonarr containers see different paths than your webhook server
# Format: container_path->host_path,another_container_path->another_host_path
RADARR_PATH_MAP=/mnt/unionfs/Media/Movies->/media/Movies/movies,/mnt/unionfs/Media/Movies6->/media/Movies/movies6
SONARR_PATH_MAP=/mnt/unionfs/Media/TV->/media/TV/tv->/media/TV/tv6
# --- Optional: Additional Digital Release Sources ---
#OMDB_API_KEY=your_omdb_api_key_here
JELLYSEERR_URL=http://jellyseerr:5055
JELLYSEERR_API_KEY=
# --- Optional: Advanced Settings ---
BATCH_DELAY=5.0
MAX_CONCURRENT_SERIES=3
TIMEOUT_SECONDS=45
DEBUG=false DEBUG=false
DB_PATH=/app/data/media_dates.db
PORT=8080
+26
View File
@@ -0,0 +1,26 @@
services:
NFOguard:
build: .
container_name: NFOguard
restart: unless-stopped
user: "1000:1000"
networks:
- saltbox
ports:
- "8085:8080"
volumes:
- /mnt/unionfs/Media/:/media:rw
- ./data:/app/data:rw
- ./.env:/app/.env:ro
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
saltbox:
external: true
+99 -17
View File
@@ -312,8 +312,11 @@ class RadarrClient:
"sortKey": "date", "sortDirection": "ascending" "sortKey": "date", "sortDirection": "ascending"
}) })
if not data: if not data:
_log("DEBUG", f"No history data returned for movie_id {movie_id}")
return None return None
items = data if isinstance(data, list) else data.get("records") or [] items = data if isinstance(data, list) else data.get("records") or []
_log("DEBUG", f"Found {len(items)} history items for movie_id {movie_id}")
first_grab = None first_grab = None
for it in items: for it in items:
ev = (it.get("eventType") or it.get("type") or "").lower() ev = (it.get("eventType") or it.get("type") or "").lower()
@@ -323,13 +326,27 @@ class RadarrClient:
date_iso = datetime.fromisoformat(it["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") date_iso = datetime.fromisoformat(it["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
except Exception: except Exception:
date_iso = None date_iso = None
if ev in ("downloadfolderimported", "moviefileimported"):
_log("DEBUG", f"History event: type='{ev}', date={date_iso}, data={it.get('data', {})}")
# Expanded event types for Radarr v4/v5 compatibility
import_events = {
"downloadfolderimported", "moviefileimported", "imported",
"downloadimported", "fileimported", "movieimported"
}
if ev in import_events:
if date_iso: if date_iso:
_log("INFO", f"Found import event '{ev}' at {date_iso} for movie_id {movie_id}")
return date_iso return date_iso
if ev == "grabbed" and date_iso and first_grab is None: if ev == "grabbed" and date_iso and first_grab is None:
first_grab = date_iso first_grab = date_iso
if first_grab: if first_grab:
_log("INFO", f"Using grab date {first_grab} as fallback for movie_id {movie_id}")
return first_grab return first_grab
_log("WARNING", f"No usable import/grab events found for movie_id {movie_id}")
return None return None
# --------------------------- # ---------------------------
@@ -523,7 +540,11 @@ def upsert_movie_nfo(
_ensure_child_text(root, "year", ymd.split("-")[0], overwrite=False) _ensure_child_text(root, "year", ymd.split("-")[0], overwrite=False)
if dateadded: if dateadded:
_ensure_child_text(root, "dateadded", dateadded, overwrite=True) if dateadded == "MANUAL_REVIEW_NEEDED":
_ensure_child_text(root, "dateadded", "MANUAL_REVIEW_NEEDED", overwrite=True)
_log("WARNING", f"Set MANUAL_REVIEW_NEEDED marker in NFO: {movie_dir}/movie.nfo")
else:
_ensure_child_text(root, "dateadded", dateadded, overwrite=True)
if lock_metadata: if lock_metadata:
_ensure_child_text(root, "lockdata", "true", overwrite=True) _ensure_child_text(root, "lockdata", "true", overwrite=True)
@@ -552,9 +573,16 @@ def _parse_date_to_iso(d: str) -> Optional[str]:
try: try:
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d): if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d):
dt = datetime.fromisoformat(d).replace(tzinfo=timezone.utc) dt = datetime.fromisoformat(d).replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds") else:
dt = datetime.fromisoformat(d.replace("Z", "+00:00")) dt = datetime.fromisoformat(d.replace("Z", "+00:00")).astimezone(timezone.utc)
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
# Reject dates that are too recent (prevent bogus timestamps)
now = datetime.now(timezone.utc)
if (now - dt).days < 1:
_log("WARNING", f"Rejecting recent timestamp (less than 1 day old): {d} -> {dt.isoformat()}")
return None
return dt.isoformat(timespec="seconds")
except Exception: except Exception:
return None return None
@@ -587,21 +615,32 @@ def _strategy_movie_date_from_movie_obj(movie_obj: Dict[str, Any], which: str) -
def _collect_digital_candidates(imdb_id: str, movie_obj: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]: def _collect_digital_candidates(imdb_id: str, movie_obj: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]:
cands: List[Tuple[str, str]] = [] cands: List[Tuple[str, str]] = []
_log("DEBUG", f"Collecting digital dates for {imdb_id}")
if movie_obj: if movie_obj:
iso = _strategy_movie_date_from_movie_obj(movie_obj, "digital") iso = _strategy_movie_date_from_movie_obj(movie_obj, "digital")
if iso: if iso:
cands.append((iso, "radarr:digitalRelease")) cands.append((iso, "radarr:digitalRelease"))
_log("DEBUG", f"Found Radarr digital date: {iso}")
iso = tmdb_digital_date_from_imdb(imdb_id) iso = tmdb_digital_date_from_imdb(imdb_id)
if iso: if iso:
cands.append((iso, "tmdb:digital")) cands.append((iso, "tmdb:digital"))
_log("DEBUG", f"Found TMDB digital date: {iso}")
tmdb_id = _tmdb_id_from_imdb(imdb_id) tmdb_id = _tmdb_id_from_imdb(imdb_id)
if tmdb_id: if tmdb_id:
iso = jellyseerr_digital_date_from_tmdb(tmdb_id) iso = jellyseerr_digital_date_from_tmdb(tmdb_id)
if iso: if iso:
cands.append((iso, "jellyseerr:digital")) cands.append((iso, "jellyseerr:digital"))
_log("DEBUG", f"Found Jellyseerr digital date: {iso}")
iso = omdb_dvd_date(imdb_id) iso = omdb_dvd_date(imdb_id)
if iso: if iso:
cands.append((iso, "omdb:dvd")) cands.append((iso, "omdb:dvd"))
_log("DEBUG", f"Found OMDb DVD date: {iso}")
_log("INFO", f"Found {len(cands)} digital date candidates: {[f'{src}={date}' for date, src in cands]}")
return cands return cands
def _pick_earliest(cands: List[Tuple[str, str]]) -> Optional[Tuple[str, str]]: def _pick_earliest(cands: List[Tuple[str, str]]) -> Optional[Tuple[str, str]]:
@@ -654,15 +693,24 @@ def _decide_movie_date(imdb_id: str, movie_dir: Path, poll_external: bool) -> Tu
def from_import_history() -> Tuple[Optional[str], str]: def from_import_history() -> Tuple[Optional[str], str]:
ensure_movie() ensure_movie()
if poll_external and movie_id: if not (poll_external and movie_id):
iso = _radarr_earliest_import_history_iso(movie_id) _log("DEBUG", f"Skipping import history: poll_external={poll_external}, movie_id={movie_id}")
if iso: return None, "radarr:history.skipped"
return iso, "radarr:history.import"
if poll_external and movie_id: # Try import history events first (most reliable)
iso2 = _radarr_earliest_file_dateadded_iso(movie_id) iso = _radarr_earliest_import_history_iso(movie_id)
if iso2: if iso:
return iso2, "radarr:moviefile.dateAdded" _log("INFO", f"Using Radarr import history date: {iso}")
return None, "radarr:history" return iso, "radarr:history.import"
# Fallback to moviefile dateAdded
iso2 = _radarr_earliest_file_dateadded_iso(movie_id)
if iso2:
_log("INFO", f"Using Radarr moviefile dateAdded: {iso2}")
return iso2, "radarr:moviefile.dateAdded"
_log("WARNING", f"No import dates found for movie_id {movie_id}")
return None, "radarr:history.none"
def from_radarr_field(which: str) -> Tuple[Optional[str], str]: def from_radarr_field(which: str) -> Tuple[Optional[str], str]:
ensure_movie() ensure_movie()
@@ -715,33 +763,67 @@ def _decide_movie_date(imdb_id: str, movie_dir: Path, poll_external: bool) -> Tu
return digi_iso, "radarr:digitalRelease" return digi_iso, "radarr:digitalRelease"
# Priority flows # Priority flows
_log("INFO", f"Date selection for {imdb_id}: priority={config.movie_priority}, poll_external={poll_external}")
if config.movie_priority == "import_then_digital": if config.movie_priority == "import_then_digital":
_log("DEBUG", "Trying import history first (import_then_digital mode)")
imp_iso, imp_src = from_import_history() imp_iso, imp_src = from_import_history()
if imp_iso: if imp_iso:
_log("INFO", f"Selected import date: {imp_iso} ({imp_src})")
return imp_iso, imp_src, released_iso return imp_iso, imp_src, released_iso
_log("DEBUG", "No import date found, trying digital release dates")
digi_iso, digi_src = from_external_digital_bundle() digi_iso, digi_src = from_external_digital_bundle()
digi_iso, digi_src = maybe_sanitize_digital(digi_iso) digi_iso, digi_src = maybe_sanitize_digital(digi_iso)
if digi_iso: if digi_iso:
_log("INFO", f"Selected digital date: {digi_iso} ({digi_src})")
return digi_iso, digi_src, released_iso return digi_iso, digi_src, released_iso
_log("DEBUG", "No digital date found, trying cinema release")
cine_iso, cine_src = from_radarr_field("cinema") cine_iso, cine_src = from_radarr_field("cinema")
if cine_iso: if cine_iso:
_log("INFO", f"Selected cinema date: {cine_iso} ({cine_src})")
return cine_iso, cine_src, released_iso or cine_iso return cine_iso, cine_src, released_iso or cine_iso
_log("DEBUG", "No external dates found, falling back to file mtime")
mtime_iso, mtime_src = from_file_mtime() mtime_iso, mtime_src = from_file_mtime()
return mtime_iso, mtime_src, released_iso if mtime_iso:
_log("WARNING", f"Using file mtime as last resort: {mtime_iso} ({mtime_src})")
return mtime_iso, mtime_src, released_iso
# No valid date found - create manual review marker
_log("ERROR", f"No valid dateadded found for {imdb_id} - marking for MANUAL_REVIEW_NEEDED")
return "MANUAL_REVIEW_NEEDED", "manual_review_required", released_iso
# digital_then_import # digital_then_import
_log("DEBUG", "Trying digital release dates first (digital_then_import mode)")
digi_iso, digi_src = from_external_digital_bundle() digi_iso, digi_src = from_external_digital_bundle()
digi_iso, digi_src = maybe_sanitize_digital(digi_iso) digi_iso, digi_src = maybe_sanitize_digital(digi_iso)
if digi_iso: if digi_iso:
_log("INFO", f"Selected digital date: {digi_iso} ({digi_src})")
return digi_iso, digi_src, released_iso return digi_iso, digi_src, released_iso
_log("DEBUG", "No digital date found, trying import history")
imp_iso, imp_src = from_import_history() imp_iso, imp_src = from_import_history()
if imp_iso: if imp_iso:
_log("INFO", f"Selected import date: {imp_iso} ({imp_src})")
return imp_iso, imp_src, released_iso return imp_iso, imp_src, released_iso
_log("DEBUG", "No import date found, trying cinema release")
cine_iso, cine_src = from_radarr_field("cinema") cine_iso, cine_src = from_radarr_field("cinema")
if cine_iso: if cine_iso:
_log("INFO", f"Selected cinema date: {cine_iso} ({cine_src})")
return cine_iso, cine_src, released_iso or cine_iso return cine_iso, cine_src, released_iso or cine_iso
_log("DEBUG", "No external dates found, falling back to file mtime")
mtime_iso, mtime_src = from_file_mtime() mtime_iso, mtime_src = from_file_mtime()
return mtime_iso, mtime_src, released_iso if mtime_iso:
_log("WARNING", f"Using file mtime as last resort: {mtime_iso} ({mtime_src})")
return mtime_iso, mtime_src, released_iso
# No valid date found - create manual review marker
_log("ERROR", f"No valid dateadded found for {imdb_id} - marking for MANUAL_REVIEW_NEEDED")
return "MANUAL_REVIEW_NEEDED", "manual_review_required", released_iso
# --------------------------- # ---------------------------
# Movie apply # Movie apply
@@ -854,7 +936,7 @@ def apply_movie(movie_dir: Path, conn: sqlite3.Connection):
brand=config.manager_brand, brand=config.manager_brand,
) )
if config.fix_dir_mtimes and dateadded_iso: if config.fix_dir_mtimes and dateadded_iso and dateadded_iso != "MANUAL_REVIEW_NEEDED":
set_movie_file_mtime(movie_dir, dateadded_iso) set_movie_file_mtime(movie_dir, dateadded_iso)
conn.commit() conn.commit()