feat: MAJOR - implement webhook-first architecture with database priority
Revolutionary Workflow Changes (v0.7.0): This fundamentally changes how NFOGuard handles timestamps and processing priority. Webhooks = Source of Truth: - First webhook fires → Use current timestamp → Store as permanent database entry - Subsequent webhooks (upgrades) → Check database → Use original first-seen timestamp - No more API calls during webhook processing → Webhook timing is ultimate authority - Movies and TV episodes both use webhook-first approach Manual Scans = Smart Fallback Logic: - Priority 1: Our database (webhook timestamps) - database always wins - Priority 2: Sonarr/Radarr import history (first import only) - Priority 3: Air date as dateadded (final fallback) Technical Implementation: - Enhanced _get_webhook_episode_date() to use current timestamp as source of truth - Added webhook_mode parameter to process_movie() for separate webhook logic - All manual scans prioritize database entries before making API calls - All timestamps converted to container timezone (Eastern Time) - Enhanced debug logging for database lookups and timestamp decisions Expected Workflow: First download at 8:30am → webhook timestamp stored in database Upgrade at 2:00pm → database entry found → original 8:30am timestamp preserved Manual scan → database entry found → 8:30am timestamp used This ensures the first-seen webhook timestamp is the permanent source of truth, with upgrades and manual scans always preserving the original download time.
This commit is contained in:
+51
-2
@@ -327,6 +327,55 @@ sonarr-nfo-cache | [2025-09-14T09:37:00-04:00] INFO: Batched movie webhook...
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### 🆕 v0.7.0 MAJOR: Webhook-First Architecture & Database Priority (September 2025)
|
||||
|
||||
**🎯 Revolutionary Workflow Changes:**
|
||||
This is a **major architectural change** that fundamentally alters how NFOGuard handles timestamps.
|
||||
|
||||
**📡 Webhooks = Source of Truth:**
|
||||
- **First webhook seen** → Use current timestamp → Store as **permanent source of truth**
|
||||
- **Subsequent webhooks** (upgrades) → Check database → **Use original first-seen timestamp**
|
||||
- **No more API calls during webhooks** → Webhook timing is the ultimate authority
|
||||
|
||||
**🔍 Manual Scans = Smart Fallback Logic:**
|
||||
- **Priority 1**: Our database (webhook timestamps) → **Database always wins**
|
||||
- **Priority 2**: Sonarr/Radarr import history (first import only)
|
||||
- **Priority 3**: Air date as dateadded (final fallback)
|
||||
|
||||
**⚡ Implementation Details:**
|
||||
- **TV Episodes**: Enhanced `_get_webhook_episode_date()` → uses current timestamp as source of truth
|
||||
- **Movies**: New webhook mode in `process_movie()` → separate logic for webhooks vs manual scans
|
||||
- **Database Priority**: Manual scans now **always** check database first before API calls
|
||||
- **Local Timezone**: All timestamps converted to container timezone (Eastern Time)
|
||||
|
||||
**🛠️ Technical Changes:**
|
||||
- **Webhook Sources**: Episodes/movies show `webhook:first_seen` instead of complex API sources
|
||||
- **Database Verification**: Added immediate verification logging after database writes
|
||||
- **Enhanced Debug**: Comprehensive logging shows database lookups and timestamp decisions
|
||||
- **Clean Separation**: Webhook processing vs manual scan processing use different code paths
|
||||
|
||||
**📋 Expected Workflow:**
|
||||
```
|
||||
# First Download (8:30am EST)
|
||||
Webhook fires → Use 8:30am timestamp → Store in database → NFO shows 8:30am
|
||||
|
||||
# Upgrade Download (2:00pm EST)
|
||||
Webhook fires → Check database → Find 8:30am entry → NFO still shows 8:30am
|
||||
|
||||
# Manual Scan
|
||||
Curl scan → Check database → Find 8:30am webhook entry → NFO shows 8:30am
|
||||
```
|
||||
|
||||
**🏆 Benefits:**
|
||||
- **Consistent Timestamps**: First download time is preserved forever
|
||||
- **Simplified Logic**: Webhooks don't query APIs, just use current time
|
||||
- **Performance**: Faster webhook processing with database-first approach
|
||||
- **Predictable**: Clear hierarchy - database beats everything else
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** September 14, 2025
|
||||
**Version:** v0.6.7
|
||||
**Status:** Enhanced Production Ready
|
||||
**Version:** v0.7.0
|
||||
**Status:** Production Ready - Major Architecture
|
||||
+45
-33
@@ -738,8 +738,9 @@ class TVProcessor:
|
||||
_log("WARNING", f"No import history found for S{season_num:02d}E{episode_num:02d}, using air date as fallback")
|
||||
return aired, aired, "sonarr:episode.airDateUtc"
|
||||
|
||||
# Step 5: Last resort - current time (shouldn't happen in normal operation)
|
||||
current_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
# Step 5: Last resort - current time in local timezone (shouldn't happen in normal operation)
|
||||
local_tz = _get_local_timezone()
|
||||
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
|
||||
_log("WARNING", f"No data found for S{season_num:02d}E{episode_num:02d}, using current time")
|
||||
return None, current_time, "fallback:current_time"
|
||||
|
||||
@@ -857,9 +858,13 @@ class TVProcessor:
|
||||
else:
|
||||
_log("DEBUG", f"No database entry found for IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}")
|
||||
|
||||
# Step 2: Try to get real import date from Sonarr history (even for webhooks)
|
||||
# Step 2: Webhook = Source of Truth - use current timestamp in local timezone
|
||||
local_tz = _get_local_timezone()
|
||||
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
|
||||
_log("INFO", f"Webhook episode processing - using current timestamp as source of truth: {current_time}")
|
||||
|
||||
# Step 3: Get aired date for reference (but don't use for dateadded)
|
||||
aired = None
|
||||
import_date = None
|
||||
|
||||
# Try Sonarr metadata first for air date
|
||||
if series_metadata and "episodes" in series_metadata:
|
||||
@@ -869,33 +874,20 @@ class TVProcessor:
|
||||
if aired:
|
||||
break
|
||||
|
||||
# Try Sonarr API for both air date and import history
|
||||
if self.sonarr.enabled:
|
||||
# Fallback to Sonarr API for air date if not in metadata
|
||||
if not aired and self.sonarr.enabled:
|
||||
try:
|
||||
series = self.sonarr.series_by_imdb(imdb_id)
|
||||
if series:
|
||||
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||
for ep in episodes:
|
||||
if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num:
|
||||
if not aired: # Only get aired if we don't have it from metadata
|
||||
aired = ep.get("airDateUtc")
|
||||
# Get import history for this episode
|
||||
import_date = self.sonarr.get_episode_import_history(ep["id"])
|
||||
if import_date:
|
||||
# Convert import date to local timezone
|
||||
local_import_date = convert_utc_to_local(import_date)
|
||||
_log("INFO", f"Found Sonarr import history for webhook S{season_num:02d}E{episode_num:02d}: {local_import_date}")
|
||||
return aired, local_import_date, "sonarr:history.import"
|
||||
aired = ep.get("airDateUtc")
|
||||
break
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Error getting episode data from Sonarr: {e}")
|
||||
_log("DEBUG", f"Error getting air date from Sonarr: {e}")
|
||||
|
||||
# Step 3: Last resort - use current time (webhook triggered but no history found)
|
||||
local_tz = _get_local_timezone()
|
||||
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
|
||||
_log("INFO", f"No Sonarr import history found - using current time for webhook: {current_time}")
|
||||
|
||||
return aired, current_time, "webhook:new_download"
|
||||
return aired, current_time, "webhook:first_seen"
|
||||
|
||||
|
||||
class MovieProcessor:
|
||||
@@ -943,7 +935,7 @@ class MovieProcessor:
|
||||
|
||||
return None
|
||||
|
||||
def process_movie(self, movie_path: Path) -> None:
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None:
|
||||
"""Process a movie directory"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(movie_path)
|
||||
if not imdb_id:
|
||||
@@ -967,15 +959,35 @@ class MovieProcessor:
|
||||
# Get existing dates
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
|
||||
# Determine if we should query APIs
|
||||
should_query = (
|
||||
config.movie_poll_mode == "always" or
|
||||
(config.movie_poll_mode == "if_missing" and not existing) or
|
||||
(config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime")
|
||||
)
|
||||
|
||||
# Decide dates
|
||||
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
|
||||
# Handle webhook mode - prioritize database, then use current timestamp
|
||||
if webhook_mode:
|
||||
if existing and existing.get("dateadded"):
|
||||
_log("INFO", f"Webhook processing - using existing database entry: {existing['dateadded']} (source: {existing.get('source', 'unknown')})")
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
else:
|
||||
# Use current timestamp as source of truth for webhooks
|
||||
local_tz = _get_local_timezone()
|
||||
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
|
||||
_log("INFO", f"Webhook processing - using current timestamp as source of truth: {current_time}")
|
||||
|
||||
# Still get release date for reference
|
||||
released = None
|
||||
if config.movie_poll_mode != "never":
|
||||
radarr_movie = self.radarr.movie_by_imdb(imdb_id) if self.radarr.api_key else None
|
||||
if radarr_movie:
|
||||
released = self._parse_date_to_iso(radarr_movie.get("inCinemas"))
|
||||
|
||||
dateadded, source = current_time, "webhook:first_seen"
|
||||
else:
|
||||
# Manual scan mode - determine if we should query APIs
|
||||
should_query = (
|
||||
config.movie_poll_mode == "always" or
|
||||
(config.movie_poll_mode == "if_missing" and not existing) or
|
||||
(config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime")
|
||||
)
|
||||
|
||||
# Use existing movie date decision logic
|
||||
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
|
||||
|
||||
# Skip processing if no valid date found and file dates disabled
|
||||
if dateadded is None:
|
||||
@@ -1296,7 +1308,7 @@ class WebhookBatcher:
|
||||
_log("INFO", f"Using series processing mode (fallback or configured)")
|
||||
tv_processor.process_series(path_obj)
|
||||
elif media_type == 'movie':
|
||||
movie_processor.process_movie(path_obj)
|
||||
movie_processor.process_movie(path_obj, webhook_mode=True)
|
||||
else:
|
||||
_log("ERROR", f"Unknown media type: {media_type}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user