From 7af7b1c43e5178f2a95991f81b2705f373711307 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 14 Sep 2025 09:56:27 -0400 Subject: [PATCH] feat: complete timezone support for NFO dateadded fields - Fix NFO dateadded timestamps to use local timezone instead of UTC - Convert Radarr/Sonarr import dates from UTC to local timezone for NFO files - Update webhook episode processing to use local time for new downloads - Convert file modification time fallbacks to local timezone - Preserve historical dates (aired, premiered) in original UTC format - Replace all placeholder logging functions with centralized timezone-aware logging - Add convert_utc_to_local() utility function for timestamp conversion - Now all logs AND NFO files show consistent local timezone formatting Users with TZ=America/New_York will now see: - Logs: [2025-09-14T09:37:00-04:00] - NFO files: 2025-09-14T09:37:00-04:00 --- SUMMARY.md | 18 +++++++--- VERSION | 2 +- core/database.py | 5 +-- core/logging.py | 26 +++++++++++++- core/nfo_manager.py | 5 +-- core/path_mapper.py | 5 +-- nfoguard.py | 83 ++++++++++++++++++++++++++++++++++++++------- 7 files changed, 113 insertions(+), 31 deletions(-) diff --git a/SUMMARY.md b/SUMMARY.md index 4c20521..d8bd095 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -231,22 +231,32 @@ Preserve movie and TV import dates in Emby/Jellyfin/Plex by preventing upgraded - **Consistent Import**: All client modules now use centralized `core.logging._log` **🏆 User Experience Improvements:** -- **Consistent Timestamps**: All logs now show the same timezone format -- **Easier Troubleshooting**: Timestamps match user's local environment +- **Consistent Timestamps**: All logs AND NFO files now show the same timezone format +- **Easier Troubleshooting**: Timestamps match user's local environment everywhere - **No More Confusion**: No need to mentally convert between UTC and local time -- **Production Ready**: Robust fallbacks ensure logging works in all environments +- **Production Ready**: Robust fallbacks ensure timezone handling works in all environments +- **NFO File Accuracy**: Movie and TV episode `` fields now respect container timezone **📋 Before/After Example:** ``` # Before (Inconsistent) sonarr-nfo-cache | [2025-09-14T09:37:00.338045] DEBUG: Mapped Radarr path... sonarr-nfo-cache | [2025-09-14T13:37:00+00:00] INFO: Batched movie webhook... +2025-09-14T13:49:00+00:00 # After (Consistent) sonarr-nfo-cache | [2025-09-14T09:37:00-04:00] DEBUG: Mapped Radarr path... sonarr-nfo-cache | [2025-09-14T09:37:00-04:00] INFO: Batched movie webhook... +2025-09-14T09:49:00-04:00 ``` +**🔧 Additional NFO Timezone Fixes:** +- **Movie Import Dates**: Convert UTC timestamps from Radarr to local timezone in NFO files +- **TV Episode Downloads**: Webhook-triggered episodes now use local time for `` +- **File Modification Times**: Fallback file mtime dates now respect container timezone +- **Historical Date Preservation**: `` and `` dates remain historically accurate +- **Centralized Logging**: All modules now use consistent timezone-aware logging system + --- ## 📝 Development Workflow Instructions @@ -260,5 +270,5 @@ sonarr-nfo-cache | [2025-09-14T09:37:00-04:00] INFO: Batched movie webhook... --- **Last Updated:** September 14, 2025 -**Version:** v0.6.4 +**Version:** v0.6.5 **Status:** Enhanced Production Ready \ No newline at end of file diff --git a/VERSION b/VERSION index d2b13eb..ef5e445 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.4 +0.6.5 diff --git a/core/database.py b/core/database.py index bb90d36..7e9ee9d 100644 --- a/core/database.py +++ b/core/database.py @@ -7,10 +7,7 @@ from pathlib import Path from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple, Any - -def _log(level: str, msg: str): - """Placeholder logging function - replace with your actual logger""" - print(f"[{datetime.now().isoformat()}] {level}: {msg}") +from core.logging import _log class NFOGuardDatabase: diff --git a/core/logging.py b/core/logging.py index 7b81655..a673f42 100644 --- a/core/logging.py +++ b/core/logging.py @@ -26,4 +26,28 @@ def _get_local_timezone(): def _log(level: str, msg: str): """Basic logging function that writes to console""" tz = _get_local_timezone() - print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}") \ No newline at end of file + print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}") + +def convert_utc_to_local(utc_iso_string: str) -> str: + """Convert UTC ISO timestamp to local timezone timestamp""" + if not utc_iso_string: + return utc_iso_string + + try: + # Parse UTC timestamp + if utc_iso_string.endswith('Z'): + dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) + elif '+00:00' in utc_iso_string: + dt_utc = datetime.fromisoformat(utc_iso_string) + else: + # Assume UTC if no timezone info + dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) + + # Convert to local timezone + local_tz = _get_local_timezone() + dt_local = dt_utc.astimezone(local_tz) + + return dt_local.isoformat(timespec='seconds') + except Exception: + # If conversion fails, return original + return utc_iso_string \ No newline at end of file diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 3feba9d..f765660 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -9,10 +9,7 @@ from datetime import datetime, timezone from typing import Optional, Dict, Any import xml.etree.ElementTree as ET - -def _log(level: str, msg: str): - """Placeholder logging function - replace with your actual logger""" - print(f"[{datetime.now().isoformat()}] {level}: {msg}") +from core.logging import _log class NFOManager: diff --git a/core/path_mapper.py b/core/path_mapper.py index ce6f5e1..2eb4c1c 100644 --- a/core/path_mapper.py +++ b/core/path_mapper.py @@ -9,10 +9,7 @@ from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Tuple - -def _log(level: str, msg: str): - """Placeholder logging function - replace with your actual logger""" - print(f"[{datetime.now().isoformat()}] {level}: {msg}") +from core.logging import _log class PathMapper: diff --git a/nfoguard.py b/nfoguard.py index 01de621..6f8630d 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -104,10 +104,30 @@ def _mask_sensitive_data(msg: str) -> str: return masked_msg +def _get_local_timezone(): + """Get the local timezone, respecting TZ environment variable""" + tz_name = os.environ.get('TZ', 'UTC') + + try: + # Try zoneinfo first (Python 3.9+) + return ZoneInfo(tz_name) + except ImportError: + # Fallback for older Python versions + try: + import pytz + return pytz.timezone(tz_name) + except: + # Final fallback to UTC + return timezone.utc + except: + # If zone name is invalid, fallback to UTC + return timezone.utc + def _log(level: str, msg: str): """Enhanced logging that writes to both console and file with sensitive data masking""" masked_msg = _mask_sensitive_data(msg) - print(f"[{datetime.now(timezone.utc).isoformat(timespec='seconds')}] {level}: {masked_msg}") + tz = _get_local_timezone() + print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {masked_msg}") try: file_logger = _setup_file_logging() @@ -115,6 +135,30 @@ def _log(level: str, msg: str): except Exception as e: print(f"File logging error: {e}") +def convert_utc_to_local(utc_iso_string: str) -> str: + """Convert UTC ISO timestamp to local timezone timestamp""" + if not utc_iso_string: + return utc_iso_string + + try: + # Parse UTC timestamp + if utc_iso_string.endswith('Z'): + dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) + elif '+00:00' in utc_iso_string: + dt_utc = datetime.fromisoformat(utc_iso_string) + else: + # Assume UTC if no timezone info + dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) + + # Convert to local timezone + local_tz = _get_local_timezone() + dt_local = dt_utc.astimezone(local_tz) + + return dt_local.isoformat(timespec='seconds') + except Exception: + # If conversion fails, return original + return utc_iso_string + # Initialize logging _setup_file_logging() @@ -674,8 +718,10 @@ class TVProcessor: aired = ep.get("airDateUtc") import_date = self.sonarr.get_episode_import_history(ep["id"]) if import_date: - _log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {import_date}") - return aired, import_date, "sonarr:history.import" + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {local_import_date}") + return aired, local_import_date, "sonarr:history.import" break except Exception as e: _log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}") @@ -795,8 +841,9 @@ class TVProcessor: _log("INFO", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']}") return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") - # Step 2: This is a new download (webhook triggered) - use current time - current_time = datetime.now(timezone.utc).isoformat(timespec="seconds") + # Step 2: This is a new download (webhook triggered) - use current time in local timezone + local_tz = _get_local_timezone() + current_time = datetime.now(local_tz).isoformat(timespec="seconds") _log("INFO", f"New episode download detected via webhook - using current time: {current_time}") # Step 3: Try to get air date for reference @@ -956,8 +1003,10 @@ class MovieProcessor: # Fall through to release date logic below # Check if we got a real import date or just file date fallback elif import_date and import_source != "radarr:db.file.dateAdded": - _log("INFO", f"✅ Movie {imdb_id}: Using import date {import_date} from {import_source}") - return import_date, import_source, released + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + return local_import_date, import_source, released # Get digital release date for comparison/fallback _log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date fallback...") @@ -971,13 +1020,17 @@ class MovieProcessor: _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") return digital_date, digital_source, released else: - _log("INFO", f"✅ Movie {imdb_id}: Keeping file date {import_date} - digital date not reasonable") - return import_date, import_source, released + # Convert file date to local timezone for NFO files + local_file_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable") + return local_file_date, import_source, released # Use whichever we have if import_date: - _log("INFO", f"✅ Movie {imdb_id}: Using import date {import_date} from {import_source}") - return import_date, import_source, released + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") + return local_import_date, import_source, released elif digital_date: _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") return digital_date, digital_source, released @@ -1002,7 +1055,9 @@ class MovieProcessor: if movie_id: import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) if import_date: - return import_date, import_source, released + # Convert import date to local timezone for NFO files + local_import_date = convert_utc_to_local(import_date) + return local_import_date, import_source, released # Last resort: file mtime (if allowed) if config.allow_file_date_fallback: @@ -1078,7 +1133,9 @@ class MovieProcessor: if newest_mtime: try: - iso_date = datetime.fromtimestamp(newest_mtime, tz=timezone.utc).isoformat(timespec="seconds") + # Use local timezone for file modification times + local_tz = _get_local_timezone() + iso_date = datetime.fromtimestamp(newest_mtime, tz=local_tz).isoformat(timespec="seconds") return iso_date, "file:mtime", None except Exception: pass