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:
+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