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: <dateadded>2025-09-14T09:37:00-04:00</dateadded>
This commit is contained in:
2025-09-14 09:56:27 -04:00
parent 5c7300fc74
commit 7af7b1c43e
7 changed files with 113 additions and 31 deletions
+14 -4
View File
@@ -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` - **Consistent Import**: All client modules now use centralized `core.logging._log`
**🏆 User Experience Improvements:** **🏆 User Experience Improvements:**
- **Consistent Timestamps**: All logs now show the same timezone format - **Consistent Timestamps**: All logs AND NFO files now show the same timezone format
- **Easier Troubleshooting**: Timestamps match user's local environment - **Easier Troubleshooting**: Timestamps match user's local environment everywhere
- **No More Confusion**: No need to mentally convert between UTC and local time - **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 `<dateadded>` fields now respect container timezone
**📋 Before/After Example:** **📋 Before/After Example:**
``` ```
# Before (Inconsistent) # Before (Inconsistent)
sonarr-nfo-cache | [2025-09-14T09:37:00.338045] DEBUG: Mapped Radarr path... 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... sonarr-nfo-cache | [2025-09-14T13:37:00+00:00] INFO: Batched movie webhook...
<dateadded>2025-09-14T13:49:00+00:00</dateadded> <!-- UTC in NFO files -->
# After (Consistent) # 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] DEBUG: Mapped Radarr path...
sonarr-nfo-cache | [2025-09-14T09:37:00-04:00] INFO: Batched movie webhook... sonarr-nfo-cache | [2025-09-14T09:37:00-04:00] INFO: Batched movie webhook...
<dateadded>2025-09-14T09:49:00-04:00</dateadded> <!-- Local timezone in NFO files -->
``` ```
**🔧 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 `<dateadded>`
- **File Modification Times**: Fallback file mtime dates now respect container timezone
- **Historical Date Preservation**: `<aired>` and `<premiered>` dates remain historically accurate
- **Centralized Logging**: All modules now use consistent timezone-aware logging system
--- ---
## 📝 Development Workflow Instructions ## 📝 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 **Last Updated:** September 14, 2025
**Version:** v0.6.4 **Version:** v0.6.5
**Status:** Enhanced Production Ready **Status:** Enhanced Production Ready
+1 -1
View File
@@ -1 +1 @@
0.6.4 0.6.5
+1 -4
View File
@@ -7,10 +7,7 @@ from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
from core.logging import _log
def _log(level: str, msg: str):
"""Placeholder logging function - replace with your actual logger"""
print(f"[{datetime.now().isoformat()}] {level}: {msg}")
class NFOGuardDatabase: class NFOGuardDatabase:
+24
View File
@@ -27,3 +27,27 @@ def _log(level: str, msg: str):
"""Basic logging function that writes to console""" """Basic logging function that writes to console"""
tz = _get_local_timezone() tz = _get_local_timezone()
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}") 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
+1 -4
View File
@@ -9,10 +9,7 @@ from datetime import datetime, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from core.logging import _log
def _log(level: str, msg: str):
"""Placeholder logging function - replace with your actual logger"""
print(f"[{datetime.now().isoformat()}] {level}: {msg}")
class NFOManager: class NFOManager:
+1 -4
View File
@@ -9,10 +9,7 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from core.logging import _log
def _log(level: str, msg: str):
"""Placeholder logging function - replace with your actual logger"""
print(f"[{datetime.now().isoformat()}] {level}: {msg}")
class PathMapper: class PathMapper:
+70 -13
View File
@@ -104,10 +104,30 @@ def _mask_sensitive_data(msg: str) -> str:
return masked_msg 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): def _log(level: str, msg: str):
"""Enhanced logging that writes to both console and file with sensitive data masking""" """Enhanced logging that writes to both console and file with sensitive data masking"""
masked_msg = _mask_sensitive_data(msg) 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: try:
file_logger = _setup_file_logging() file_logger = _setup_file_logging()
@@ -115,6 +135,30 @@ def _log(level: str, msg: str):
except Exception as e: except Exception as e:
print(f"File logging error: {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 # Initialize logging
_setup_file_logging() _setup_file_logging()
@@ -674,8 +718,10 @@ class TVProcessor:
aired = ep.get("airDateUtc") aired = ep.get("airDateUtc")
import_date = self.sonarr.get_episode_import_history(ep["id"]) import_date = self.sonarr.get_episode_import_history(ep["id"])
if import_date: if import_date:
_log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {import_date}") # Convert import date to local timezone for NFO files
return aired, import_date, "sonarr:history.import" 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 break
except Exception as e: except Exception as e:
_log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {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']}") _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") return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing")
# Step 2: This is a new download (webhook triggered) - use current time # Step 2: This is a new download (webhook triggered) - use current time in local timezone
current_time = datetime.now(timezone.utc).isoformat(timespec="seconds") 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}") _log("INFO", f"New episode download detected via webhook - using current time: {current_time}")
# Step 3: Try to get air date for reference # Step 3: Try to get air date for reference
@@ -956,8 +1003,10 @@ class MovieProcessor:
# Fall through to release date logic below # Fall through to release date logic below
# Check if we got a real import date or just file date fallback # Check if we got a real import date or just file date fallback
elif import_date and import_source != "radarr:db.file.dateAdded": elif import_date and import_source != "radarr:db.file.dateAdded":
_log("INFO", f"✅ Movie {imdb_id}: Using import date {import_date} from {import_source}") # Convert import date to local timezone for NFO files
return import_date, import_source, released 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 # Get digital release date for comparison/fallback
_log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date 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") _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date")
return digital_date, digital_source, released return digital_date, digital_source, released
else: else:
_log("INFO", f"✅ Movie {imdb_id}: Keeping file date {import_date} - digital date not reasonable") # Convert file date to local timezone for NFO files
return import_date, import_source, released 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 # Use whichever we have
if import_date: if import_date:
_log("INFO", f"✅ Movie {imdb_id}: Using import date {import_date} from {import_source}") # Convert import date to local timezone for NFO files
return import_date, import_source, released 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: elif digital_date:
_log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}")
return digital_date, digital_source, released return digital_date, digital_source, released
@@ -1002,7 +1055,9 @@ class MovieProcessor:
if movie_id: if movie_id:
import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback)
if import_date: 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) # Last resort: file mtime (if allowed)
if config.allow_file_date_fallback: if config.allow_file_date_fallback:
@@ -1078,7 +1133,9 @@ class MovieProcessor:
if newest_mtime: if newest_mtime:
try: 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 return iso_date, "file:mtime", None
except Exception: except Exception:
pass pass