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:
2025-09-14 10:52:43 -04:00
parent e188706001
commit df5785e8ad
3 changed files with 97 additions and 36 deletions
+51 -2
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.6.7
0.7.0
+37 -25
View File
@@ -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"
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,14 +959,34 @@ class MovieProcessor:
# Get existing dates
existing = self.db.get_movie_dates(imdb_id)
# Determine if we should query APIs
# 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")
)
# Decide dates
# 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
@@ -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}")