diff --git a/SUMMARY.md b/SUMMARY.md index a5f315e..0f48648 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -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 \ No newline at end of file +**Version:** v0.7.0 +**Status:** Production Ready - Major Architecture \ No newline at end of file diff --git a/VERSION b/VERSION index 2228cad..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.7 +0.7.0 diff --git a/nfoguard.py b/nfoguard.py index 819b4ac..c7b3bec 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -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}")