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:
+14
-4
@@ -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
-4
@@ -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:
|
||||||
|
|||||||
+25
-1
@@ -26,4 +26,28 @@ def _get_local_timezone():
|
|||||||
def _log(level: str, msg: str):
|
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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user