Dispatcharr/apps/vod/tasks.py
SergeantPanda 5c5b866bcf fix(vod): prevent group selection loss on empty category fetch
Implemented logic to abort the VOD refresh process when the provider returns no categories, preserving existing group selections. This change prevents unintended deletions of category relations and ensures that accounts retain their current settings during transient provider outages or API issues. Updated logging to reflect the abort condition for better traceability.
2026-07-09 21:26:36 +00:00

2308 lines
101 KiB
Python

from celery import shared_task, current_app, group
from django.utils import timezone
from django.db import transaction, IntegrityError
from django.db.models import Q
from apps.m3u.models import M3UAccount
from core.xtream_codes import Client as XtreamCodesClient
from .models import (
VODCategory, Series, Movie, Episode, VODLogo,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation
)
from datetime import datetime
import logging
import json
import re
logger = logging.getLogger(__name__)
def _empty_categories_should_abort(categories_data, account, category_type):
"""True when an empty provider response would wipe existing group selections."""
if categories_data:
return False
return M3UVODCategoryRelation.objects.filter(
m3u_account=account,
category__category_type=category_type,
).exclude(category__name='Uncategorized').exists()
def lookup_by_name_year(model, name_year_pairs):
"""Return {(name, year): row} for rows without TMDB/IMDB IDs.
Scoped to the names in this batch instead of scanning the full table.
"""
if not name_year_pairs:
return {}
wanted = set(name_year_pairs)
names = {name for name, _ in wanted}
found = {}
for row in model.objects.filter(
tmdb_id__isnull=True,
imdb_id__isnull=True,
name__in=names,
):
key = (row.name, row.year)
if key in wanted:
found[key] = row
return found
@shared_task
def refresh_vod_content(account_id):
"""Refresh VOD content for an M3U account with batch processing for improved performance"""
# Import here to avoid circular import
from apps.m3u.tasks import send_m3u_update
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
if account.account_type != M3UAccount.Types.XC:
logger.warning(f"VOD refresh called for non-XC account {account_id}")
return "VOD refresh only available for XtreamCodes accounts"
logger.info(f"Starting batch VOD refresh for account {account.name}")
start_time = timezone.now()
# Send start notification
send_m3u_update(account_id, "vod_refresh", 0, status="processing")
with XtreamCodesClient(
account.server_url,
account.username,
account.password,
account.get_user_agent().user_agent
) as client:
category_maps = refresh_categories(account.id, client)
if category_maps is None:
message = (
f"Provider returned no VOD categories for account {account.name}; "
"aborting VOD refresh to preserve existing category selections"
)
logger.warning(message)
send_m3u_update(account_id, "vod_refresh", 100, status="error",
message=f"VOD refresh failed: {message}")
return f"VOD refresh failed: {message}"
movie_categories, series_categories = category_maps
logger.debug("Fetching relations for filtering category filtering")
relations = { rel.category_id: rel for rel in M3UVODCategoryRelation.objects
.filter(m3u_account=account)
.select_related("category", "m3u_account")
}
# Refresh movies with batch processing (pass scan start time)
refresh_movies(client, account, movie_categories, relations, scan_start_time=start_time)
# Refresh series with batch processing (pass scan start time)
refresh_series(client, account, series_categories, relations, scan_start_time=start_time)
end_time = timezone.now()
duration = (end_time - start_time).total_seconds()
logger.info(f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds")
# Cleanup orphaned VOD content after refresh (scoped to this account only)
logger.info(f"Starting cleanup of orphaned VOD content for account {account.name}")
cleanup_result = cleanup_orphaned_vod_content(account_id=account_id, scan_start_time=start_time)
logger.info(f"VOD cleanup completed: {cleanup_result}")
# Send completion notification
send_m3u_update(account_id, "vod_refresh", 100, status="success",
message=f"VOD refresh completed in {duration:.2f} seconds")
return f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds"
except Exception as e:
import traceback
logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}")
logger.error(f"Full traceback:\n{traceback.format_exc()}")
# Send error notification
send_m3u_update(account_id, "vod_refresh", 100, status="error",
message=f"VOD refresh failed: {str(e)}")
return f"VOD refresh failed: {str(e)}"
def refresh_categories(account_id, client=None):
account = M3UAccount.objects.get(id=account_id, is_active=True)
if not client:
client = XtreamCodesClient(
account.server_url,
account.username,
account.password,
account.get_user_agent().user_agent
)
logger.info(f"Refreshing movie categories for account {account.name}")
# First, get the category list to properly map category IDs and names
logger.info("Fetching movie categories from provider...")
categories_data = client.get_vod_categories()
if _empty_categories_should_abort(categories_data, account, 'movie'):
logger.warning(
f"Provider returned no movie categories for account {account.id} "
f"({account.name}); aborting VOD refresh to preserve existing category selections"
)
return None
category_map = batch_create_categories(categories_data, 'movie', account)
# Create a mapping from provider category IDs to our category objects
movies_category_id_map = {}
for cat_data in categories_data:
cat_name = cat_data.get('category_name', 'Unknown')
provider_cat_id = cat_data.get('category_id')
our_category = category_map.get(cat_name)
if provider_cat_id and our_category:
movies_category_id_map[str(provider_cat_id)] = our_category
# Get the category list to properly map category IDs and names
logger.info("Fetching series categories from provider...")
categories_data = client.get_series_categories()
if _empty_categories_should_abort(categories_data, account, 'series'):
logger.warning(
f"Provider returned no series categories for account {account.id} "
f"({account.name}); aborting VOD refresh to preserve existing category selections"
)
return None
category_map = batch_create_categories(categories_data, 'series', account)
# Create a mapping from provider category IDs to our category objects
series_category_id_map = {}
for cat_data in categories_data:
cat_name = cat_data.get('category_name', 'Unknown')
provider_cat_id = cat_data.get('category_id')
our_category = category_map.get(cat_name)
if provider_cat_id and our_category:
series_category_id_map[str(provider_cat_id)] = our_category
return movies_category_id_map, series_category_id_map
def refresh_movies(client, account, categories_by_provider, relations, scan_start_time=None):
"""Refresh movie content using single API call for all movies"""
logger.info(f"Refreshing movies for account {account.name}")
# Ensure "Uncategorized" category exists for movies without a category
uncategorized_category, created = VODCategory.objects.get_or_create(
name="Uncategorized",
category_type="movie",
defaults={}
)
# Ensure there's a relation for the Uncategorized category
account_custom_props = account.custom_properties or {}
auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True)
uncategorized_relation, rel_created = M3UVODCategoryRelation.objects.get_or_create(
category=uncategorized_category,
m3u_account=account,
defaults={
'enabled': auto_enable_new,
'custom_properties': {}
}
)
if created:
logger.info(f"Created 'Uncategorized' category for movies")
if rel_created:
logger.info(f"Created relation for 'Uncategorized' category (enabled={auto_enable_new})")
# Add uncategorized category to relations dict for easy access
relations[uncategorized_category.id] = uncategorized_relation
# Add to categories_by_provider with a special key for items without category
categories_by_provider['__uncategorized__'] = uncategorized_category
# Get all movies in a single API call
logger.info("Fetching all movies from provider...")
all_movies_data = client.get_vod_streams() # No category_id = get all movies
# Process movies in chunks using the simple approach
chunk_size = 1000
total_movies = len(all_movies_data)
total_chunks = (total_movies + chunk_size - 1) // chunk_size if total_movies > 0 else 0
for i in range(0, total_movies, chunk_size):
chunk = all_movies_data[i:i + chunk_size]
chunk_num = (i // chunk_size) + 1
logger.info(f"Processing movie chunk {chunk_num}/{total_chunks} ({len(chunk)} movies)")
process_movie_batch(account, chunk, categories_by_provider, relations, scan_start_time)
del all_movies_data
logger.info(f"Completed processing all {total_movies} movies in {total_chunks} chunks")
def refresh_series(client, account, categories_by_provider, relations, scan_start_time=None):
"""Refresh series content using single API call for all series"""
logger.info(f"Refreshing series for account {account.name}")
# Ensure "Uncategorized" category exists for series without a category
uncategorized_category, created = VODCategory.objects.get_or_create(
name="Uncategorized",
category_type="series",
defaults={}
)
# Ensure there's a relation for the Uncategorized category
account_custom_props = account.custom_properties or {}
auto_enable_new = account_custom_props.get("auto_enable_new_groups_series", True)
uncategorized_relation, rel_created = M3UVODCategoryRelation.objects.get_or_create(
category=uncategorized_category,
m3u_account=account,
defaults={
'enabled': auto_enable_new,
'custom_properties': {}
}
)
if created:
logger.info(f"Created 'Uncategorized' category for series")
if rel_created:
logger.info(f"Created relation for 'Uncategorized' category (enabled={auto_enable_new})")
# Add uncategorized category to relations dict for easy access
relations[uncategorized_category.id] = uncategorized_relation
# Add to categories_by_provider with a special key for items without category
categories_by_provider['__uncategorized__'] = uncategorized_category
# Get all series in a single API call
logger.info("Fetching all series from provider...")
all_series_data = client.get_series() # No category_id = get all series
# Process series in chunks using the simple approach
chunk_size = 1000
total_series = len(all_series_data)
total_chunks = (total_series + chunk_size - 1) // chunk_size if total_series > 0 else 0
for i in range(0, total_series, chunk_size):
chunk = all_series_data[i:i + chunk_size]
chunk_num = (i // chunk_size) + 1
logger.info(f"Processing series chunk {chunk_num}/{total_chunks} ({len(chunk)} series)")
process_series_batch(account, chunk, categories_by_provider, relations, scan_start_time)
del all_series_data
logger.info(f"Completed processing all {total_series} series in {total_chunks} chunks")
def batch_create_categories(categories_data, category_type, account):
"""Create categories in batch and return a mapping"""
category_names = [cat.get('category_name', 'Unknown') for cat in categories_data]
relations_to_create = []
# Get existing categories
logger.debug(f"Starting VOD {category_type} category refresh")
existing_categories = {
cat.name: cat for cat in VODCategory.objects.filter(
name__in=category_names,
category_type=category_type
)
}
logger.debug(f"Found {len(existing_categories)} existing categories")
# Check if we should auto-enable new categories based on account settings
account_custom_props = account.custom_properties or {}
if category_type == 'movie':
auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True)
else: # series
auto_enable_new = account_custom_props.get("auto_enable_new_groups_series", True)
# Create missing categories in batch
new_categories = []
for name in category_names:
if name not in existing_categories:
# Always create new categories
new_categories.append(VODCategory(name=name, category_type=category_type))
else:
# Existing category - create relationship with enabled based on auto_enable setting
# (category exists globally but is new to this account)
relations_to_create.append(M3UVODCategoryRelation(
category=existing_categories[name],
m3u_account=account,
custom_properties={},
enabled=auto_enable_new,
))
logger.debug(f"{len(new_categories)} new categories found")
logger.debug(f"{len(relations_to_create)} existing categories found for account")
if new_categories:
logger.debug("Creating new categories...")
created_categories = list(VODCategory.bulk_create_and_fetch(new_categories, ignore_conflicts=True))
# Create relations for newly created categories with enabled based on auto_enable setting
for cat in created_categories:
if not auto_enable_new:
logger.info(f"New {category_type} category '{cat.name}' created but DISABLED - auto_enable_new_groups is disabled for account {account.id}")
relations_to_create.append(
M3UVODCategoryRelation(
category=cat,
m3u_account=account,
custom_properties={},
enabled=auto_enable_new,
)
)
# Convert to dictionary for easy lookup
newly_created = {cat.name: cat for cat in created_categories}
existing_categories.update(newly_created)
# Create missing relations
logger.debug("Updating category account relations...")
M3UVODCategoryRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True)
# Delete orphaned category relationships (categories no longer in the M3U source)
# Exclude "Uncategorized" from cleanup as it's a special category we manage
current_category_ids = set(existing_categories[name].id for name in category_names)
existing_relations = M3UVODCategoryRelation.objects.filter(
m3u_account=account,
category__category_type=category_type
).select_related('category')
relations_to_delete = [
rel for rel in existing_relations
if rel.category_id not in current_category_ids and rel.category.name != "Uncategorized"
]
if relations_to_delete:
M3UVODCategoryRelation.objects.filter(
id__in=[rel.id for rel in relations_to_delete]
).delete()
logger.info(f"Deleted {len(relations_to_delete)} orphaned {category_type} category relationships for account {account.id}: {[rel.category.name for rel in relations_to_delete]}")
# Check if any of the deleted relationships left categories with no remaining associations
orphaned_category_ids = []
for rel in relations_to_delete:
category = rel.category
# Check if this category has any remaining M3U account relationships
remaining_relationships = M3UVODCategoryRelation.objects.filter(
category=category
).exists()
# If no relationships remain, it's safe to delete the category
if not remaining_relationships:
orphaned_category_ids.append(category.id)
logger.debug(f"Category '{category.name}' has no remaining associations and will be deleted")
# Delete orphaned categories
if orphaned_category_ids:
VODCategory.objects.filter(id__in=orphaned_category_ids).delete()
logger.info(f"Deleted {len(orphaned_category_ids)} orphaned {category_type} categories with no remaining associations")
# 🔑 Fetch all relations for this account, for all categories
# relations = { rel.id: rel for rel in M3UVODCategoryRelation.objects
# .filter(category__in=existing_categories.values(), m3u_account=account)
# .select_related("category", "m3u_account")
# }
# Attach relations to category objects
# for rel in relations:
# existing_categories[rel.category.name]['relation'] = {
# "relation_id": rel.id,
# "category_id": rel.category_id,
# "account_id": rel.m3u_account_id,
# }
return existing_categories
@shared_task
def process_movie_batch(account, batch, categories, relations, scan_start_time=None):
"""Process a batch of movies using simple bulk operations like M3U processing"""
logger.info(f"Processing movie batch of {len(batch)} movies for account {account.name}")
movies_to_create = []
movies_to_update = []
relations_to_create = []
relations_to_update = []
movie_keys = {} # For deduplication like M3U stream_hashes
# Process each movie in the batch
for movie_data in batch:
try:
stream_id = str(movie_data.get('stream_id'))
name = movie_data.get('name', 'Unknown')
# Get category with proper error handling
category = None
provider_cat_id = str(movie_data.get('category_id', '')) if movie_data.get('category_id') else None
movie_data['_provider_category_id'] = provider_cat_id
movie_data['_category_id'] = None
logger.debug(f"Checking for existing provider category ID {provider_cat_id}")
if provider_cat_id in categories:
category = categories[provider_cat_id]
movie_data['_category_id'] = category.id
logger.debug(f"Found category {category.name} (ID: {category.id}) for movie {name}")
relation = relations.get(category.id, None)
if relation and not relation.enabled:
logger.debug("Skipping disabled category")
continue
else:
# Assign to Uncategorized category if no category_id provided
logger.debug(f"No category ID provided for movie {name}, assigning to 'Uncategorized'")
category = categories.get('__uncategorized__')
if category:
movie_data['_category_id'] = category.id
# Check if uncategorized is disabled
relation = relations.get(category.id, None)
if relation and not relation.enabled:
logger.debug("Skipping disabled 'Uncategorized' category")
continue
# Extract metadata
year = extract_year_from_data(movie_data, 'name')
tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb')
imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb')
# Clean empty string IDs and zero values (some providers use 0 to indicate no ID)
if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0':
tmdb_id = None
if imdb_id == '' or imdb_id == 0 or imdb_id == '0':
imdb_id = None
# Create a unique key for this movie (priority: TMDB > IMDB > name+year)
if tmdb_id:
movie_key = f"tmdb_{tmdb_id}"
elif imdb_id:
movie_key = f"imdb_{imdb_id}"
else:
movie_key = f"name_{name}_{year or 'None'}"
# Skip duplicates in this batch
if movie_key in movie_keys:
continue
# Prepare movie properties
description = movie_data.get('description') or movie_data.get('plot') or ''
rating = normalize_rating(movie_data.get('rating') or movie_data.get('vote_average'))
genre = movie_data.get('genre') or movie_data.get('category_name') or ''
duration_secs = extract_duration_from_data(movie_data)
trailer_raw = movie_data.get('trailer') or movie_data.get('youtube_trailer') or ''
trailer = extract_string_from_array_or_string(trailer_raw) if trailer_raw else None
logo_url = movie_data.get('stream_icon') or ''
director = extract_string_from_array_or_string(
movie_data.get('director') or ''
)
actors_raw = movie_data.get('actors') or movie_data.get('cast') or ''
if isinstance(actors_raw, list):
actors = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
else:
actors = actors_raw.strip() if actors_raw else None
release_date = movie_data.get('release_date') or movie_data.get('releasedate') or ''
custom_props = {}
if trailer:
custom_props['youtube_trailer'] = trailer
if director:
custom_props['director'] = director
if actors:
custom_props['actors'] = actors
if release_date:
custom_props['release_date'] = release_date
movie_props = {
'name': name,
'year': year,
'tmdb_id': tmdb_id,
'imdb_id': imdb_id,
'description': description,
'rating': rating,
'genre': genre,
'duration_secs': duration_secs,
'custom_properties': custom_props or None,
}
movie_keys[movie_key] = {
'props': movie_props,
'stream_id': stream_id,
'category': category,
'movie_data': movie_data,
'logo_url': logo_url # Keep logo URL for later processing
}
except Exception as e:
logger.error(f"Error preparing movie {movie_data.get('name', 'Unknown')}: {str(e)}")
# Collect all logo URLs and create logos in batch
logo_urls = set()
logo_url_to_name = {} # Map logo URLs to movie names
for data in movie_keys.values():
logo_url = data.get('logo_url')
if logo_url and len(logo_url) <= 500: # Ignore overly long URLs (likely embedded image data)
logo_urls.add(logo_url)
# Map this logo URL to the movie name (use first occurrence if multiple movies share same logo)
if logo_url not in logo_url_to_name:
movie_name = data['props'].get('name', 'Unknown Movie')
logo_url_to_name[logo_url] = movie_name
# Get existing logos
existing_logos = {
logo.url: logo for logo in VODLogo.objects.filter(url__in=logo_urls)
} if logo_urls else {}
# Create missing logos
logos_to_create = []
for logo_url in logo_urls:
if logo_url not in existing_logos:
movie_name = logo_url_to_name.get(logo_url, 'Unknown Movie')
logos_to_create.append(VODLogo(url=logo_url, name=movie_name))
if logos_to_create:
try:
VODLogo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
# Refresh existing_logos with newly created ones
new_logo_urls = [logo.url for logo in logos_to_create]
newly_created = {
logo.url: logo for logo in VODLogo.objects.filter(url__in=new_logo_urls)
}
existing_logos.update(newly_created)
logger.info(f"Created {len(newly_created)} new VOD logos for movies")
except Exception as e:
logger.warning(f"Failed to create VOD logos: {e}")
# Get existing movies based on our keys
existing_movies = {}
# Query by TMDB IDs
tmdb_keys = [k for k in movie_keys.keys() if k.startswith('tmdb_')]
tmdb_ids = [k.replace('tmdb_', '') for k in tmdb_keys]
if tmdb_ids:
for movie in Movie.objects.filter(tmdb_id__in=tmdb_ids):
existing_movies[f"tmdb_{movie.tmdb_id}"] = movie
# Query by IMDB IDs
imdb_keys = [k for k in movie_keys.keys() if k.startswith('imdb_')]
imdb_ids = [k.replace('imdb_', '') for k in imdb_keys]
if imdb_ids:
for movie in Movie.objects.filter(imdb_id__in=imdb_ids):
existing_movies[f"imdb_{movie.imdb_id}"] = movie
# Query by name+year for movies without external IDs
name_year_keys = [k for k in movie_keys.keys() if k.startswith('name_')]
if name_year_keys:
name_year_pairs = [
(movie_keys[k]['props']['name'], movie_keys[k]['props'].get('year'))
for k in name_year_keys
]
for key_tuple, movie in lookup_by_name_year(Movie, name_year_pairs).items():
existing_movies[f"name_{key_tuple[0]}_{key_tuple[1] or 'None'}"] = movie
# Get existing relations
stream_ids = [data['stream_id'] for data in movie_keys.values()]
existing_relations = {
rel.stream_id: rel for rel in M3UMovieRelation.objects.filter(
m3u_account=account,
stream_id__in=stream_ids
).select_related('movie')
}
# Process each movie
for movie_key, data in movie_keys.items():
movie_props = data['props']
stream_id = data['stream_id']
category = data['category']
movie_data = data['movie_data']
logo_url = data.get('logo_url')
if movie_key in existing_movies:
# Update existing movie
movie = existing_movies[movie_key]
updated = False
for field, value in movie_props.items():
if field == 'custom_properties':
# Merge: preserve advanced-refresh keys; don't overwrite director/actors/release_date if already set.
existing_cp = movie.custom_properties or {}
incoming_cp = value or {}
merged = dict(existing_cp)
for k, v in incoming_cp.items():
if k in ('director', 'actors', 'release_date'):
if not existing_cp.get(k):
merged[k] = v
else:
merged[k] = v
if merged != existing_cp:
movie.custom_properties = merged
updated = True
elif getattr(movie, field) != value:
setattr(movie, field, value)
updated = True
# Handle logo assignment for existing movies
logo_updated = False
if logo_url and len(logo_url) <= 500:
if logo_url in existing_logos:
new_logo = existing_logos[logo_url]
if movie.logo_id != new_logo.id:
movie._logo_to_update = new_logo
logo_updated = True
elif movie.logo_id:
logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference")
movie._logo_to_update = None
logo_updated = True
elif (not logo_url or len(logo_url) > 500) and movie.logo_id:
# Clear logo if no logo URL provided or URL is too long
movie._logo_to_update = None
logo_updated = True
if updated or logo_updated:
movies_to_update.append(movie)
else:
# Create new movie
movie = Movie(**movie_props)
# Assign logo if available
if logo_url and len(logo_url) <= 500 and logo_url in existing_logos:
movie.logo = existing_logos[logo_url]
movies_to_create.append(movie)
# Handle relation
if stream_id in existing_relations:
# Update existing relation
relation = existing_relations[stream_id]
relation.movie = movie
relation.category = category
relation.container_extension = movie_data.get('container_extension', 'mp4')
relation.custom_properties = {
'basic_data': movie_data,
'detailed_fetched': False
}
relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan
relations_to_update.append(relation)
else:
# Create new relation
relation = M3UMovieRelation(
m3u_account=account,
movie=movie,
category=category,
stream_id=stream_id,
container_extension=movie_data.get('container_extension', 'mp4'),
custom_properties={
'basic_data': movie_data,
'detailed_fetched': False
},
last_seen=scan_start_time or timezone.now() # Mark as seen during this scan
)
relations_to_create.append(relation)
# Execute batch operations
logger.info(f"Executing batch operations: {len(movies_to_create)} movies to create, {len(movies_to_update)} to update")
try:
with transaction.atomic():
# First, create new movies and get their IDs
created_movies = {}
if movies_to_create:
# Bulk query to check which movies already exist
tmdb_ids = [m.tmdb_id for m in movies_to_create if m.tmdb_id]
imdb_ids = [m.imdb_id for m in movies_to_create if m.imdb_id]
name_year_pairs = [(m.name, m.year) for m in movies_to_create if not m.tmdb_id and not m.imdb_id]
existing_by_tmdb = {m.tmdb_id: m for m in Movie.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {}
existing_by_imdb = {m.imdb_id: m for m in Movie.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {}
existing_by_name_year = lookup_by_name_year(Movie, name_year_pairs)
# Check each movie against the bulk query results
movies_actually_created = []
for movie in movies_to_create:
existing = None
if movie.tmdb_id and movie.tmdb_id in existing_by_tmdb:
existing = existing_by_tmdb[movie.tmdb_id]
elif movie.imdb_id and movie.imdb_id in existing_by_imdb:
existing = existing_by_imdb[movie.imdb_id]
elif not movie.tmdb_id and not movie.imdb_id:
existing = existing_by_name_year.get((movie.name, movie.year))
if existing:
created_movies[id(movie)] = existing
else:
movies_actually_created.append(movie)
created_movies[id(movie)] = movie
# Bulk create only movies that don't exist
if movies_actually_created:
Movie.objects.bulk_create(movies_actually_created)
# Update existing movies
if movies_to_update:
# First, update all fields except logo to avoid unsaved related object issues
Movie.objects.bulk_update(movies_to_update, [
'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id',
'duration_secs', 'custom_properties'
])
# Handle logo updates separately to avoid bulk_update issues
for movie in movies_to_update:
if hasattr(movie, '_logo_to_update'):
movie.logo = movie._logo_to_update
movie.save(update_fields=['logo'])
# Update relations to reference the correct movie objects (with PKs)
for relation in relations_to_create:
if id(relation.movie) in created_movies:
relation.movie = created_movies[id(relation.movie)]
for relation in relations_to_update:
if id(relation.movie) in created_movies:
relation.movie = created_movies[id(relation.movie)]
# All movies now have PKs, safe to bulk create/update relations
if relations_to_create:
M3UMovieRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True)
if relations_to_update:
M3UMovieRelation.objects.bulk_update(relations_to_update, [
'movie', 'category', 'container_extension', 'custom_properties', 'last_seen'
])
logger.info("Movie batch processing completed successfully!")
return f"Movie batch processed: {len(movies_to_create)} created, {len(movies_to_update)} updated"
except Exception as e:
logger.error(f"Movie batch processing failed: {str(e)}")
return f"Movie batch processing failed: {str(e)}"
@shared_task
def process_series_batch(account, batch, categories, relations, scan_start_time=None):
"""Process a batch of series using simple bulk operations like M3U processing"""
logger.info(f"Processing series batch of {len(batch)} series for account {account.name}")
series_to_create = []
series_to_update = []
relations_to_create = []
relations_to_update = []
series_keys = {} # For deduplication like M3U stream_hashes
# Process each series in the batch
for series_data in batch:
try:
series_id = str(series_data.get('series_id'))
name = series_data.get('name', 'Unknown')
# Get category with proper error handling
category = None
provider_cat_id = str(series_data.get('category_id', '')) if series_data.get('category_id') else None
series_data['_provider_category_id'] = provider_cat_id
series_data['_category_id'] = None
if provider_cat_id in categories:
category = categories[provider_cat_id]
series_data['_category_id'] = category.id
logger.debug(f"Found category {category.name} (ID: {category.id}) for series {name}")
relation = relations.get(category.id, None)
if relation and not relation.enabled:
logger.debug("Skipping disabled category")
continue
else:
# Assign to Uncategorized category if no category_id provided
logger.debug(f"No category ID provided for series {name}, assigning to 'Uncategorized'")
category = categories.get('__uncategorized__')
if category:
series_data['_category_id'] = category.id
# Check if uncategorized is disabled
relation = relations.get(category.id, None)
if relation and not relation.enabled:
logger.debug("Skipping disabled 'Uncategorized' category")
continue
# Extract metadata
year = extract_year(series_data.get('releaseDate', ''))
if not year and series_data.get('release_date'):
year = extract_year(series_data.get('release_date'))
tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id')
imdb_id = series_data.get('imdb') or series_data.get('imdb_id')
# Clean empty string IDs and zero values (some providers use 0 to indicate no ID)
if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0':
tmdb_id = None
if imdb_id == '' or imdb_id == 0 or imdb_id == '0':
imdb_id = None
# Create a unique key for this series (priority: TMDB > IMDB > name+year)
if tmdb_id:
series_key = f"tmdb_{tmdb_id}"
elif imdb_id:
series_key = f"imdb_{imdb_id}"
else:
series_key = f"name_{name}_{year or 'None'}"
# Skip duplicates in this batch
if series_key in series_keys:
continue
# Prepare series properties
description = series_data.get('plot', '')
rating = normalize_rating(series_data.get('rating'))
genre = series_data.get('genre', '')
logo_url = series_data.get('cover') or ''
# Extract additional metadata for custom_properties
additional_metadata = {}
for key in ['backdrop_path', 'poster_path', 'original_name', 'first_air_date', 'last_air_date',
'episode_run_time', 'status', 'type', 'cast', 'director', 'country', 'language',
'releaseDate', 'youtube_trailer', 'category_id', 'age', 'seasons']:
value = series_data.get(key)
if value:
# For string-like fields that might be arrays, extract clean strings
if key == 'cast':
if isinstance(value, list):
clean_value = ', '.join(s.strip() for s in value if s and str(s).strip()) or None
else:
clean_value = extract_string_from_array_or_string(value)
if clean_value:
additional_metadata[key] = clean_value
elif key in ['poster_path', 'youtube_trailer', 'director']:
clean_value = extract_string_from_array_or_string(value)
if clean_value:
additional_metadata[key] = clean_value
elif key == 'backdrop_path':
clean_value = extract_string_from_array_or_string(value)
if clean_value:
additional_metadata[key] = [clean_value]
else:
# For other fields, keep as-is if not null/empty
if value is not None and value != '' and value != []:
additional_metadata[key] = value
series_props = {
'name': name,
'year': year,
'tmdb_id': tmdb_id,
'imdb_id': imdb_id,
'description': description,
'rating': rating,
'genre': genre,
'custom_properties': additional_metadata if additional_metadata else None,
}
series_keys[series_key] = {
'props': series_props,
'series_id': series_id,
'category': category,
'series_data': series_data,
'logo_url': logo_url # Keep logo URL for later processing
}
except Exception as e:
logger.error(f"Error preparing series {series_data.get('name', 'Unknown')}: {str(e)}")
# Collect all logo URLs and create logos in batch
logo_urls = set()
logo_url_to_name = {} # Map logo URLs to series names
for data in series_keys.values():
logo_url = data.get('logo_url')
if logo_url and len(logo_url) <= 500: # Ignore overly long URLs (likely embedded image data)
logo_urls.add(logo_url)
# Map this logo URL to the series name (use first occurrence if multiple series share same logo)
if logo_url not in logo_url_to_name:
series_name = data['props'].get('name', 'Unknown Series')
logo_url_to_name[logo_url] = series_name
# Get existing logos
existing_logos = {
logo.url: logo for logo in VODLogo.objects.filter(url__in=logo_urls)
} if logo_urls else {}
# Create missing logos
logos_to_create = []
for logo_url in logo_urls:
if logo_url not in existing_logos:
series_name = logo_url_to_name.get(logo_url, 'Unknown Series')
logos_to_create.append(VODLogo(url=logo_url, name=series_name))
if logos_to_create:
try:
VODLogo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
# Refresh existing_logos with newly created ones
new_logo_urls = [logo.url for logo in logos_to_create]
newly_created = {
logo.url: logo for logo in VODLogo.objects.filter(url__in=new_logo_urls)
}
existing_logos.update(newly_created)
logger.info(f"Created {len(newly_created)} new VOD logos for series")
except Exception as e:
logger.warning(f"Failed to create VOD logos: {e}")
# Get existing series based on our keys - same pattern as movies
existing_series = {}
# Query by TMDB IDs
tmdb_keys = [k for k in series_keys.keys() if k.startswith('tmdb_')]
tmdb_ids = [k.replace('tmdb_', '') for k in tmdb_keys]
if tmdb_ids:
for series in Series.objects.filter(tmdb_id__in=tmdb_ids):
existing_series[f"tmdb_{series.tmdb_id}"] = series
# Query by IMDB IDs
imdb_keys = [k for k in series_keys.keys() if k.startswith('imdb_')]
imdb_ids = [k.replace('imdb_', '') for k in imdb_keys]
if imdb_ids:
for series in Series.objects.filter(imdb_id__in=imdb_ids):
existing_series[f"imdb_{series.imdb_id}"] = series
# Query by name+year for series without external IDs
name_year_keys = [k for k in series_keys.keys() if k.startswith('name_')]
if name_year_keys:
name_year_pairs = [
(series_keys[k]['props']['name'], series_keys[k]['props'].get('year'))
for k in name_year_keys
]
for key_tuple, series in lookup_by_name_year(Series, name_year_pairs).items():
existing_series[f"name_{key_tuple[0]}_{key_tuple[1] or 'None'}"] = series
# Get existing relations
series_ids = [data['series_id'] for data in series_keys.values()]
existing_relations = {
rel.external_series_id: rel for rel in M3USeriesRelation.objects.filter(
m3u_account=account,
external_series_id__in=series_ids
).select_related('series')
}
# Process each series
for series_key, data in series_keys.items():
series_props = data['props']
series_id = data['series_id']
category = data['category']
series_data = data['series_data']
logo_url = data.get('logo_url')
if series_key in existing_series:
# Update existing series
series = existing_series[series_key]
updated = False
for field, value in series_props.items():
if field == 'custom_properties':
if value != series.custom_properties:
series.custom_properties = value
updated = True
elif getattr(series, field) != value:
setattr(series, field, value)
updated = True
# Handle logo assignment for existing series
logo_updated = False
if logo_url and len(logo_url) <= 500:
if logo_url in existing_logos:
new_logo = existing_logos[logo_url]
if series.logo_id != new_logo.id:
series._logo_to_update = new_logo
logo_updated = True
elif series.logo_id:
# Logo URL exists but logo creation failed or logo not found
# Clear the orphaned logo reference
logger.warning(f"Logo URL provided but logo not found in database for series '{series.name}', clearing logo reference")
series._logo_to_update = None
logo_updated = True
elif (not logo_url or len(logo_url) > 500) and series.logo_id:
# Clear logo if no logo URL provided or URL is too long
series._logo_to_update = None
logo_updated = True
if updated or logo_updated:
series_to_update.append(series)
else:
# Create new series
series = Series(**series_props)
# Assign logo if available
if logo_url and len(logo_url) <= 500 and logo_url in existing_logos:
series.logo = existing_logos[logo_url]
series_to_create.append(series)
# Handle relation
if series_id in existing_relations:
# Update existing relation
relation = existing_relations[series_id]
relation.series = series
relation.category = category
relation.custom_properties = {
'basic_data': series_data,
'detailed_fetched': False,
'episodes_fetched': False
}
relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan
relations_to_update.append(relation)
else:
# Create new relation
relation = M3USeriesRelation(
m3u_account=account,
series=series,
category=category,
external_series_id=series_id,
custom_properties={
'basic_data': series_data,
'detailed_fetched': False,
'episodes_fetched': False
},
last_seen=scan_start_time or timezone.now() # Mark as seen during this scan
)
relations_to_create.append(relation)
# Execute batch operations
logger.info(f"Executing batch operations: {len(series_to_create)} series to create, {len(series_to_update)} to update")
try:
with transaction.atomic():
# First, create new series and get their IDs
created_series = {}
if series_to_create:
# Bulk query to check which series already exist
tmdb_ids = [s.tmdb_id for s in series_to_create if s.tmdb_id]
imdb_ids = [s.imdb_id for s in series_to_create if s.imdb_id]
name_year_pairs = [(s.name, s.year) for s in series_to_create if not s.tmdb_id and not s.imdb_id]
existing_by_tmdb = {s.tmdb_id: s for s in Series.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {}
existing_by_imdb = {s.imdb_id: s for s in Series.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {}
existing_by_name_year = lookup_by_name_year(Series, name_year_pairs)
# Check each series against the bulk query results
series_actually_created = []
for series in series_to_create:
existing = None
if series.tmdb_id and series.tmdb_id in existing_by_tmdb:
existing = existing_by_tmdb[series.tmdb_id]
elif series.imdb_id and series.imdb_id in existing_by_imdb:
existing = existing_by_imdb[series.imdb_id]
elif not series.tmdb_id and not series.imdb_id:
existing = existing_by_name_year.get((series.name, series.year))
if existing:
created_series[id(series)] = existing
else:
series_actually_created.append(series)
created_series[id(series)] = series
# Bulk create only series that don't exist
if series_actually_created:
Series.objects.bulk_create(series_actually_created)
# Update existing series
if series_to_update:
# First, update all fields except logo to avoid unsaved related object issues
Series.objects.bulk_update(series_to_update, [
'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id',
'custom_properties'
])
# Handle logo updates separately to avoid bulk_update issues
for series in series_to_update:
if hasattr(series, '_logo_to_update'):
series.logo = series._logo_to_update
series.save(update_fields=['logo'])
# Update relations to reference the correct series objects (with PKs)
for relation in relations_to_create:
if id(relation.series) in created_series:
relation.series = created_series[id(relation.series)]
for relation in relations_to_update:
if id(relation.series) in created_series:
relation.series = created_series[id(relation.series)]
# All series now have PKs, safe to bulk create/update relations
if relations_to_create:
M3USeriesRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True)
if relations_to_update:
M3USeriesRelation.objects.bulk_update(relations_to_update, [
'series', 'category', 'custom_properties', 'last_seen'
])
logger.info("Series batch processing completed successfully!")
return f"Series batch processed: {len(series_to_create)} created, {len(series_to_update)} updated"
except Exception as e:
logger.error(f"Series batch processing failed: {str(e)}")
return f"Series batch processing failed: {str(e)}"
# Helper functions for year and date extraction
def extract_duration_from_data(movie_data):
"""Extract duration in seconds from movie data"""
duration_secs = None
# Try to extract duration from various possible fields
if movie_data.get('duration_secs'):
duration_secs = int(movie_data.get('duration_secs'))
elif movie_data.get('duration'):
# Handle duration that might be in different formats
duration_str = str(movie_data.get('duration'))
if duration_str.isdigit():
duration_secs = int(duration_str) * 60 # Assume minutes if just a number
else:
# Try to parse time format like "01:30:00"
try:
time_parts = duration_str.split(':')
if len(time_parts) == 3:
hours, minutes, seconds = map(int, time_parts)
duration_secs = (hours * 3600) + (minutes * 60) + seconds
elif len(time_parts) == 2:
minutes, seconds = map(int, time_parts)
duration_secs = minutes * 60 + seconds
except (ValueError, AttributeError):
pass
return duration_secs
def normalize_rating(rating_value):
"""Normalize rating value by converting commas to decimals and validating as float"""
if not rating_value:
return None
try:
# Convert to string for processing
rating_str = str(rating_value).strip()
if not rating_str or rating_str == '':
return None
# Replace comma with decimal point (European format)
rating_str = rating_str.replace(',', '.')
# Try to convert to float
rating_float = float(rating_str)
# Return as string to maintain compatibility with existing code
# but ensure it's a valid numeric format
return str(rating_float)
except (ValueError, TypeError, AttributeError):
# If conversion fails, discard the rating
logger.debug(f"Invalid rating value discarded: {rating_value}")
return None
def extract_year(date_string):
"""Extract year from date string"""
if not date_string:
return None
try:
return int(date_string.split('-')[0])
except (ValueError, IndexError):
return None
def extract_year_from_title(title):
"""Extract year from movie title if present"""
if not title:
return None
# Pattern for (YYYY) format
pattern1 = r'\((\d{4})\)'
# Pattern for - YYYY format
pattern2 = r'\s-\s(\d{4})'
# Pattern for YYYY at the end
pattern3 = r'\s(\d{4})$'
for pattern in [pattern1, pattern2, pattern3]:
match = re.search(pattern, title)
if match:
year = int(match.group(1))
# Validate year is reasonable (between 1900 and current year + 5)
if 1900 <= year <= 2030:
return year
return None
def extract_year_from_data(data, title_key='name'):
"""Extract year from various data sources with fallback options"""
try:
# First try the year field
year = data.get('year')
if year and str(year).strip() and str(year).strip() != '':
try:
year_int = int(year)
if 1900 <= year_int <= 2030:
return year_int
except (ValueError, TypeError):
pass
# Try releaseDate or release_date fields
for date_field in ['releaseDate', 'release_date']:
date_value = data.get(date_field)
if date_value and isinstance(date_value, str) and date_value.strip():
# Extract year from date format like "2011-09-19"
try:
year_str = date_value.split('-')[0].strip()
if year_str:
year = int(year_str)
if 1900 <= year <= 2030:
return year
except (ValueError, IndexError):
continue
# Finally try extracting from title
title = data.get(title_key, '')
if title and title.strip():
return extract_year_from_title(title)
except Exception:
# Don't fail processing if year extraction fails
pass
return None
def extract_date_from_data(data):
"""Extract date from various data sources with fallback options"""
try:
for date_field in ['air_date', 'releasedate', 'release_date']:
date_value = data.get(date_field)
if date_value and isinstance(date_value, str) and date_value.strip():
parsed = parse_date(date_value)
if parsed:
return parsed
except Exception:
# Don't fail processing if date extraction fails
pass
return None
def parse_date(date_string):
"""Parse date string into a datetime object"""
if not date_string:
return None
try:
# Try to parse ISO format first
return datetime.fromisoformat(date_string)
except ValueError:
# Fallback to parsing with strptime for common formats
try:
return datetime.strptime(date_string, '%Y-%m-%d')
except ValueError:
return None # Return None if parsing fails
# Episode processing and other advanced features
def refresh_series_episodes(account, series, external_series_id, episodes_data=None):
"""Refresh episodes for a series - only called on-demand"""
try:
if not episodes_data:
# Fetch detailed series info including episodes
with XtreamCodesClient(
account.server_url,
account.username,
account.password,
account.get_user_agent().user_agent
) as client:
series_info = client.get_series_info(external_series_id)
if series_info:
# Update series with detailed info
info = series_info.get('info', {})
if info:
# Only update fields if new value is non-empty and either no existing value or existing value is empty
updated = False
if should_update_field(series.description, info.get('plot')):
series.description = extract_string_from_array_or_string(info.get('plot'))
updated = True
normalized_rating = normalize_rating(info.get('rating'))
if normalized_rating and (not series.rating or not str(series.rating).strip()):
series.rating = normalized_rating
updated = True
if should_update_field(series.genre, info.get('genre')):
series.genre = extract_string_from_array_or_string(info.get('genre'))
updated = True
year = extract_year_from_data(info)
if year and not series.year:
series.year = year
updated = True
if updated:
series.save()
episodes_data = series_info.get('episodes', {})
else:
episodes_data = {}
# Fetch the series relation once — used both to pass into batch_process_episodes
# (so episode relations get the FK set) and to update metadata afterwards.
series_relation = M3USeriesRelation.objects.filter(
m3u_account=account,
external_series_id=external_series_id
).first()
# Process all episodes in batch
batch_process_episodes(account, series, episodes_data, series_relation=series_relation)
if series_relation:
custom_props = series_relation.custom_properties or {}
custom_props['episodes_fetched'] = True
custom_props['detailed_fetched'] = True
series_relation.custom_properties = custom_props
series_relation.last_episode_refresh = timezone.now()
series_relation.save()
except Exception as e:
logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}")
def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None):
"""Process episodes in batches for better performance.
Note: Multiple streams can represent the same episode (e.g., different languages
or qualities). Each stream has a unique stream_id, but they share the same
season/episode number. We create one Episode record per (series, season, episode)
and multiple M3UEpisodeRelation records pointing to it.
series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so
that CASCADE correctly removes episode relations when their parent series relation
is deleted, and so that stale-stream cleanup is scoped precisely to relations that
came from this specific provider query.
"""
if not episodes_data:
return
# Flatten episodes data
all_episodes_data = []
for season_num, season_episodes in episodes_data.items():
for episode_data in season_episodes:
episode_data['_season_number'] = int(season_num)
all_episodes_data.append(episode_data)
if not all_episodes_data:
return
logger.info(f"Batch processing {len(all_episodes_data)} episodes for series {series.name}")
# Extract episode identifiers
# Note: episode_keys may have duplicates when multiple streams represent same episode
episode_keys = set() # Use set to track unique episode keys
episode_ids = []
for episode_data in all_episodes_data:
season_num = episode_data['_season_number']
episode_num = episode_data.get('episode_num', 0)
episode_keys.add((series.id, season_num, episode_num))
episode_ids.append(str(episode_data.get('id')))
# Pre-fetch existing episodes
existing_episodes = {}
for episode in Episode.objects.filter(series=series):
key = (episode.series_id, episode.season_number, episode.episode_number)
existing_episodes[key] = episode
# Pre-fetch existing episode relations
existing_relations = {
rel.stream_id: rel for rel in M3UEpisodeRelation.objects.filter(
m3u_account=account,
stream_id__in=episode_ids
).select_related('episode')
}
# Prepare batch operations
episodes_to_create = []
episodes_to_update = []
relations_to_create = []
relations_to_update = []
# Track episodes we're creating in this batch to avoid duplicates
# Key: (series_id, season_number, episode_number) -> Episode object
episodes_pending_creation = {}
for episode_data in all_episodes_data:
try:
episode_id = str(episode_data.get('id'))
episode_name = episode_data.get('title', 'Unknown Episode')
# Ensure season and episode numbers are integers (API may return strings)
try:
season_number = int(episode_data['_season_number'])
except (ValueError, TypeError) as e:
logger.warning(f"Invalid season_number '{episode_data.get('_season_number')}' for episode '{episode_name}': {e}")
season_number = 0
try:
episode_number = int(episode_data.get('episode_num', 0))
except (ValueError, TypeError) as e:
logger.warning(f"Invalid episode_num '{episode_data.get('episode_num')}' for episode '{episode_name}': {e}")
episode_number = 0
info = episode_data.get('info', {})
# Extract episode metadata
description = info.get('plot') or info.get('overview', '') if info else ''
rating = normalize_rating(info.get('rating')) if info else None
air_date = extract_date_from_data(info) if info else None
duration_secs = info.get('duration_secs') if info else None
tmdb_id = info.get('tmdb_id') if info else None
imdb_id = info.get('imdb_id') if info else None
# Prepare custom properties
custom_props = {}
if info:
if info.get('crew'):
custom_props['crew'] = info.get('crew')
if info.get('movie_image'):
movie_image = extract_string_from_array_or_string(info.get('movie_image'))
if movie_image:
custom_props['movie_image'] = movie_image
backdrop = extract_string_from_array_or_string(info.get('backdrop_path'))
if backdrop:
custom_props['backdrop_path'] = [backdrop]
# Find existing episode - check DB first, then pending creations
episode_key = (series.id, season_number, episode_number)
episode = existing_episodes.get(episode_key)
# Check if we already have this episode pending creation (multiple streams for same episode)
if not episode and episode_key in episodes_pending_creation:
episode = episodes_pending_creation[episode_key]
logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})")
if episode:
# Update existing episode
updated = False
if episode_name != episode.name:
episode.name = episode_name
updated = True
if description != episode.description:
episode.description = description
updated = True
if rating != episode.rating:
episode.rating = rating
updated = True
if air_date != episode.air_date:
episode.air_date = air_date
updated = True
if duration_secs != episode.duration_secs:
episode.duration_secs = duration_secs
updated = True
if tmdb_id != episode.tmdb_id:
episode.tmdb_id = tmdb_id
updated = True
if imdb_id != episode.imdb_id:
episode.imdb_id = imdb_id
updated = True
if custom_props != episode.custom_properties:
episode.custom_properties = custom_props if custom_props else None
updated = True
# Only add to update list if episode has a PK (exists in DB) and isn't already in list
# Episodes pending creation don't have PKs yet and will be created via bulk_create
if updated and episode.pk and episode not in episodes_to_update:
episodes_to_update.append(episode)
else:
# Create new episode
episode = Episode(
series=series,
name=episode_name,
description=description,
air_date=air_date,
rating=rating,
duration_secs=duration_secs,
season_number=season_number,
episode_number=episode_number,
tmdb_id=tmdb_id,
imdb_id=imdb_id,
custom_properties=custom_props if custom_props else None
)
episodes_to_create.append(episode)
# Track this episode so subsequent streams with same season/episode can reuse it
episodes_pending_creation[episode_key] = episode
# Handle episode relation
if episode_id in existing_relations:
# Update existing relation
relation = existing_relations[episode_id]
relation.episode = episode
relation.series_relation = series_relation
relation.container_extension = episode_data.get('container_extension', 'mp4')
relation.custom_properties = {
'info': episode_data,
'season_number': season_number,
}
relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan
relations_to_update.append(relation)
else:
# Create new relation
relation = M3UEpisodeRelation(
m3u_account=account,
episode=episode,
series_relation=series_relation,
stream_id=episode_id,
container_extension=episode_data.get('container_extension', 'mp4'),
custom_properties={
'info': episode_data,
'season_number': season_number,
},
last_seen=scan_start_time or timezone.now() # Mark as seen during this scan
)
relations_to_create.append(relation)
except Exception as e:
logger.error(f"Error preparing episode {episode_data.get('title', 'Unknown')}: {str(e)}")
# Execute batch operations
with transaction.atomic():
# Create new episodes - use ignore_conflicts in case of race conditions
if episodes_to_create:
Episode.objects.bulk_create(episodes_to_create, ignore_conflicts=True)
# Re-fetch the created episodes to get their PKs
# We need to do this because bulk_create with ignore_conflicts doesn't set PKs
created_episode_keys = [
(ep.series_id, ep.season_number, ep.episode_number)
for ep in episodes_to_create
]
db_episodes = Episode.objects.filter(series=series)
episode_pk_map = {
(ep.series_id, ep.season_number, ep.episode_number): ep
for ep in db_episodes
}
# Update relations to point to the actual DB episodes with PKs
for relation in relations_to_create:
ep = relation.episode
key = (ep.series_id, ep.season_number, ep.episode_number)
if key in episode_pk_map:
relation.episode = episode_pk_map[key]
# Filter out relations with unsaved episodes (no PK)
# This can happen if bulk_create had a conflict and ignore_conflicts=True didn't save the episode
valid_relations_to_create = []
for relation in relations_to_create:
if relation.episode.pk is not None:
valid_relations_to_create.append(relation)
else:
season_num = relation.episode.season_number
episode_num = relation.episode.episode_number
logger.warning(
f"Skipping relation for episode S{season_num}E{episode_num} "
f"- episode not saved to database"
)
relations_to_create = valid_relations_to_create
# Update existing episodes
if episodes_to_update:
Episode.objects.bulk_update(episodes_to_update, [
'name', 'description', 'air_date', 'rating', 'duration_secs',
'tmdb_id', 'imdb_id', 'custom_properties'
])
# Create new episode relations - use ignore_conflicts for stream_id duplicates
if relations_to_create:
M3UEpisodeRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True)
# Update existing episode relations
if relations_to_update:
M3UEpisodeRelation.objects.bulk_update(relations_to_update, [
'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen'
])
# Delete relations for streams no longer returned by the provider.
# Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows
# for the same account+series (pre-migration rows whose stream is now gone — the
# update path only backfills the FK for streams still present in the response).
# Falls back to account+series scope when series_relation is None (shouldn't occur).
if series_relation is not None:
stale_qs = M3UEpisodeRelation.objects.filter(
Q(series_relation=series_relation) |
Q(series_relation__isnull=True, m3u_account=account, episode__series=series)
)
else:
stale_qs = M3UEpisodeRelation.objects.filter(
m3u_account=account,
episode__series=series
)
removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0]
if removed_count:
logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}")
logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, "
f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations")
@shared_task
def batch_refresh_series_episodes(account_id, series_ids=None):
"""
Batch refresh episodes for multiple series.
If series_ids is None, refresh all series that haven't been refreshed recently.
"""
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
if account.account_type != M3UAccount.Types.XC:
logger.warning(f"Episode refresh called for non-XC account {account_id}")
return "Episode refresh only available for XtreamCodes accounts"
# Determine which series to refresh
if series_ids:
series_relations = M3USeriesRelation.objects.filter(
m3u_account=account,
series__id__in=series_ids
).select_related('series')
else:
# Refresh series that haven't been refreshed in the last 24 hours
cutoff_time = timezone.now() - timezone.timedelta(hours=24)
series_relations = M3USeriesRelation.objects.filter(
m3u_account=account,
last_episode_refresh__lt=cutoff_time
).select_related('series')
logger.info(f"Batch refreshing episodes for {series_relations.count()} series")
with XtreamCodesClient(
account.server_url,
account.username,
account.password,
account.get_user_agent().user_agent
) as client:
refreshed_count = 0
for relation in series_relations:
try:
refresh_series_episodes(
account,
relation.series,
relation.external_series_id
)
refreshed_count += 1
except Exception as e:
logger.error(f"Error refreshing episodes for series {relation.series.name}: {str(e)}")
logger.info(f"Batch episode refresh completed for {refreshed_count} series")
return f"Batch episode refresh completed for {refreshed_count} series"
except Exception as e:
logger.error(f"Error in batch episode refresh for account {account_id}: {str(e)}")
return f"Batch episode refresh failed: {str(e)}"
@shared_task
def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=None):
"""Clean up VOD content that has no M3U relations or has stale relations"""
from datetime import timedelta
# Use scan start time as reference, or current time if not provided
reference_time = scan_start_time or timezone.now()
# Calculate cutoff date for stale relations
cutoff_date = reference_time - timedelta(days=stale_days)
# Build base query filters
base_filters = {'last_seen__lt': cutoff_date}
if account_id:
base_filters['m3u_account_id'] = account_id
logger.info(f"Cleaning up stale VOD content for account {account_id}")
else:
logger.info("Cleaning up stale VOD content across all accounts")
# Clean up stale movie relations (haven't been seen in the specified days)
stale_movie_relations = M3UMovieRelation.objects.filter(**base_filters)
stale_movie_count = stale_movie_relations.count()
stale_movie_relations.delete()
# Clean up stale series relations.
# Episode relations are removed via CASCADE on the series_relation FK.
stale_series_relations = M3USeriesRelation.objects.filter(**base_filters)
stale_series_count = stale_series_relations.count()
stale_series_relations.delete()
# Clean up movies with no relations (orphaned)
# Safe to delete even during account-specific cleanup because if ANY account
# has a relation, m3u_relations will not be null
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
orphaned_movie_count = orphaned_movies.count()
if orphaned_movie_count > 0:
logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations")
try:
orphaned_movies.delete()
except IntegrityError:
# A concurrent refresh task created a new relation for one of these movies
# between our query and the DELETE. Skip and let the next cleanup run handle it.
logger.warning(
"Skipped some orphaned movie deletions due to concurrent modifications; "
"they will be retried on the next cleanup run."
)
orphaned_movie_count = 0
# Clean up series with no relations (orphaned)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
if orphaned_series_count > 0:
logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations")
try:
orphaned_series.delete()
except IntegrityError:
logger.warning(
"Skipped some orphaned series deletions due to concurrent modifications; "
"they will be retried on the next cleanup run."
)
orphaned_series_count = 0
result = (f"Cleaned up {stale_movie_count} stale movie relations, "
f"{stale_series_count} stale series relations, "
f"{orphaned_movie_count} orphaned movies, and "
f"{orphaned_series_count} orphaned series")
logger.info(result)
return result
def handle_movie_id_conflicts(current_movie, relation, tmdb_id_to_set, imdb_id_to_set):
"""
Handle potential duplicate key conflicts when setting tmdb_id or imdb_id.
Since this is called when a user is actively accessing movie details, we always
preserve the current movie (user's selection) and merge the existing one into it.
This prevents breaking the user's current viewing experience.
Returns:
tuple: (movie_to_use, relation_was_updated)
"""
from django.db import IntegrityError
existing_movie_with_tmdb = None
existing_movie_with_imdb = None
# Check for existing movies with these IDs
if tmdb_id_to_set:
try:
existing_movie_with_tmdb = Movie.objects.get(tmdb_id=tmdb_id_to_set)
except Movie.DoesNotExist:
pass
if imdb_id_to_set:
try:
existing_movie_with_imdb = Movie.objects.get(imdb_id=imdb_id_to_set)
except Movie.DoesNotExist:
pass
# If no conflicts, proceed normally
if not existing_movie_with_tmdb and not existing_movie_with_imdb:
return current_movie, False
# Determine which existing movie has the conflicting ID (prefer TMDB match)
existing_movie = existing_movie_with_tmdb or existing_movie_with_imdb
# CRITICAL: Check if the existing movie is actually the same as the current movie
# This can happen if the current movie already has the ID we're trying to set
if existing_movie.id == current_movie.id:
logger.debug(f"Current movie {current_movie.id} already has the target ID, no conflict resolution needed")
return current_movie, False
logger.info(f"ID conflict detected: Merging existing movie '{existing_movie.name}' (ID: {existing_movie.id}) into current movie '{current_movie.name}' (ID: {current_movie.id}) to preserve user selection")
# FIRST: Clear the conflicting ID from the existing movie before any merging
if existing_movie_with_tmdb and tmdb_id_to_set:
logger.info(f"Clearing tmdb_id from existing movie {existing_movie.id} to avoid constraint violation")
existing_movie.tmdb_id = None
existing_movie.save(update_fields=['tmdb_id'])
if existing_movie_with_imdb and imdb_id_to_set:
logger.info(f"Clearing imdb_id from existing movie {existing_movie.id} to avoid constraint violation")
existing_movie.imdb_id = None
existing_movie.save(update_fields=['imdb_id'])
# THEN: Merge data from existing movie into current movie (now safe to set IDs)
merge_movie_data(source_movie=existing_movie, target_movie=current_movie,
tmdb_id_to_set=tmdb_id_to_set, imdb_id_to_set=imdb_id_to_set)
# Transfer all relations from existing movie to current movie
existing_relations = existing_movie.m3u_relations.all()
if existing_relations.exists():
logger.info(f"Transferring {existing_relations.count()} relations from existing movie {existing_movie.id} to current movie {current_movie.id}")
existing_relations.update(movie=current_movie)
# Now safe to delete the existing movie since all its relations have been transferred
logger.info(f"Deleting existing movie {existing_movie.id} '{existing_movie.name}' after merging data and transferring relations")
existing_movie.delete()
return current_movie, False # No relation update needed since we kept current movie
def merge_movie_data(source_movie, target_movie, tmdb_id_to_set=None, imdb_id_to_set=None):
"""
Merge valuable data from source_movie into target_movie.
Only overwrites target fields that are empty/None with non-empty source values.
Args:
source_movie: Movie to copy data from
target_movie: Movie to copy data to
tmdb_id_to_set: TMDB ID to set on target (overrides source tmdb_id)
imdb_id_to_set: IMDB ID to set on target (overrides source imdb_id)
"""
updated = False
# Basic fields - only fill if target is empty
if not target_movie.description and source_movie.description:
target_movie.description = source_movie.description
updated = True
logger.debug(f"Merged description from movie {source_movie.id} to {target_movie.id}")
if not target_movie.year and source_movie.year:
target_movie.year = source_movie.year
updated = True
logger.debug(f"Merged year from movie {source_movie.id} to {target_movie.id}")
if not target_movie.rating and source_movie.rating:
target_movie.rating = source_movie.rating
updated = True
logger.debug(f"Merged rating from movie {source_movie.id} to {target_movie.id}")
if not target_movie.genre and source_movie.genre:
target_movie.genre = source_movie.genre
updated = True
logger.debug(f"Merged genre from movie {source_movie.id} to {target_movie.id}")
if not target_movie.duration_secs and source_movie.duration_secs:
target_movie.duration_secs = source_movie.duration_secs
updated = True
logger.debug(f"Merged duration_secs from movie {source_movie.id} to {target_movie.id}")
if not target_movie.logo and source_movie.logo:
target_movie.logo = source_movie.logo
updated = True
logger.debug(f"Merged logo from movie {source_movie.id} to {target_movie.id}")
# Handle external IDs - use the specific IDs we want to set, or fall back to source
if not target_movie.tmdb_id:
if tmdb_id_to_set:
target_movie.tmdb_id = tmdb_id_to_set
updated = True
logger.debug(f"Set tmdb_id {tmdb_id_to_set} on movie {target_movie.id}")
elif source_movie.tmdb_id:
target_movie.tmdb_id = source_movie.tmdb_id
updated = True
logger.debug(f"Merged tmdb_id from movie {source_movie.id} to {target_movie.id}")
if not target_movie.imdb_id:
if imdb_id_to_set:
target_movie.imdb_id = imdb_id_to_set
updated = True
logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {target_movie.id}")
elif source_movie.imdb_id:
target_movie.imdb_id = source_movie.imdb_id
updated = True
logger.debug(f"Merged imdb_id from movie {source_movie.id} to {target_movie.id}")
# Merge custom properties
target_props = target_movie.custom_properties or {}
source_props = source_movie.custom_properties or {}
for key, value in source_props.items():
if value and not target_props.get(key):
target_props[key] = value
updated = True
logger.debug(f"Merged custom property '{key}' from movie {source_movie.id} to {target_movie.id}")
if updated:
target_movie.custom_properties = target_props
target_movie.save()
logger.info(f"Successfully merged data from movie {source_movie.id} into {target_movie.id}")
def handle_series_id_conflicts(current_series, relation, tmdb_id_to_set, imdb_id_to_set):
"""
Handle potential duplicate key conflicts when setting tmdb_id or imdb_id for series.
Since this is called when a user is actively accessing series details, we always
preserve the current series (user's selection) and merge the existing one into it.
This prevents breaking the user's current viewing experience.
Returns:
tuple: (series_to_use, relation_was_updated)
"""
from django.db import IntegrityError
existing_series_with_tmdb = None
existing_series_with_imdb = None
# Check for existing series with these IDs
if tmdb_id_to_set:
try:
existing_series_with_tmdb = Series.objects.get(tmdb_id=tmdb_id_to_set)
except Series.DoesNotExist:
pass
if imdb_id_to_set:
try:
existing_series_with_imdb = Series.objects.get(imdb_id=imdb_id_to_set)
except Series.DoesNotExist:
pass
# If no conflicts, proceed normally
if not existing_series_with_tmdb and not existing_series_with_imdb:
return current_series, False
# Determine which existing series has the conflicting ID (prefer TMDB match)
existing_series = existing_series_with_tmdb or existing_series_with_imdb
# CRITICAL: Check if the existing series is actually the same as the current series
# This can happen if the current series already has the ID we're trying to set
if existing_series.id == current_series.id:
logger.debug(f"Current series {current_series.id} already has the target ID, no conflict resolution needed")
return current_series, False
logger.info(f"ID conflict detected: Merging existing series '{existing_series.name}' (ID: {existing_series.id}) into current series '{current_series.name}' (ID: {current_series.id}) to preserve user selection")
# FIRST: Clear the conflicting ID from the existing series before any merging
if existing_series_with_tmdb and tmdb_id_to_set:
logger.info(f"Clearing tmdb_id from existing series {existing_series.id} to avoid constraint violation")
existing_series.tmdb_id = None
existing_series.save(update_fields=['tmdb_id'])
if existing_series_with_imdb and imdb_id_to_set:
logger.info(f"Clearing imdb_id from existing series {existing_series.id} to avoid constraint violation")
existing_series.imdb_id = None
existing_series.save(update_fields=['imdb_id'])
# THEN: Merge data from existing series into current series (now safe to set IDs)
merge_series_data(source_series=existing_series, target_series=current_series,
tmdb_id_to_set=tmdb_id_to_set, imdb_id_to_set=imdb_id_to_set)
# Transfer all relations from existing series to current series
existing_relations = existing_series.m3u_relations.all()
if existing_relations.exists():
logger.info(f"Transferring {existing_relations.count()} relations from existing series {existing_series.id} to current series {current_series.id}")
existing_relations.update(series=current_series)
# Now safe to delete the existing series since all its relations have been transferred
logger.info(f"Deleting existing series {existing_series.id} '{existing_series.name}' after merging data and transferring relations")
existing_series.delete()
return current_series, False # No relation update needed since we kept current series
def merge_series_data(source_series, target_series, tmdb_id_to_set=None, imdb_id_to_set=None):
"""
Merge valuable data from source_series into target_series.
Only overwrites target fields that are empty/None with non-empty source values.
Args:
source_series: Series to copy data from
target_series: Series to copy data to
tmdb_id_to_set: TMDB ID to set on target (overrides source tmdb_id)
imdb_id_to_set: IMDB ID to set on target (overrides source imdb_id)
"""
updated = False
# Basic fields - only fill if target is empty
if not target_series.description and source_series.description:
target_series.description = source_series.description
updated = True
logger.debug(f"Merged description from series {source_series.id} to {target_series.id}")
if not target_series.year and source_series.year:
target_series.year = source_series.year
updated = True
logger.debug(f"Merged year from series {source_series.id} to {target_series.id}")
if not target_series.rating and source_series.rating:
target_series.rating = source_series.rating
updated = True
logger.debug(f"Merged rating from series {source_series.id} to {target_series.id}")
if not target_series.genre and source_series.genre:
target_series.genre = source_series.genre
updated = True
logger.debug(f"Merged genre from series {source_series.id} to {target_series.id}")
if not target_series.logo and source_series.logo:
target_series.logo = source_series.logo
updated = True
logger.debug(f"Merged logo from series {source_series.id} to {target_series.id}")
# Handle external IDs - use the specific IDs we want to set, or fall back to source
if not target_series.tmdb_id:
if tmdb_id_to_set:
target_series.tmdb_id = tmdb_id_to_set
updated = True
logger.debug(f"Set tmdb_id {tmdb_id_to_set} on series {target_series.id}")
elif source_series.tmdb_id:
target_series.tmdb_id = source_series.tmdb_id
updated = True
logger.debug(f"Merged tmdb_id from series {source_series.id} to {target_series.id}")
if not target_series.imdb_id:
if imdb_id_to_set:
target_series.imdb_id = imdb_id_to_set
updated = True
logger.debug(f"Set imdb_id {imdb_id_to_set} on series {target_series.id}")
elif source_series.imdb_id:
target_series.imdb_id = source_series.imdb_id
updated = True
logger.debug(f"Merged imdb_id from series {source_series.id} to {target_series.id}")
# Merge custom properties
target_props = target_series.custom_properties or {}
source_props = source_series.custom_properties or {}
for key, value in source_props.items():
if value and not target_props.get(key):
target_props[key] = value
updated = True
logger.debug(f"Merged custom property '{key}' from series {source_series.id} to {target_series.id}")
if updated:
target_series.custom_properties = target_props
target_series.save()
logger.info(f"Successfully merged data from series {source_series.id} into {target_series.id}")
def is_non_empty_string(value):
"""
Helper function to safely check if a value is a non-empty string.
Returns True only if value is a string and has non-whitespace content.
"""
return isinstance(value, str) and value.strip()
def extract_string_from_array_or_string(value):
"""
Helper function to extract a string value from either a string or array.
Returns the first non-null string from an array, or the string itself.
Returns None if no valid string is found.
"""
if isinstance(value, str):
return value.strip() if value.strip() else None
elif isinstance(value, list) and value:
# Find first non-null, non-empty string in the array
for item in value:
if isinstance(item, str) and item.strip():
return item.strip()
elif item is not None and str(item).strip():
return str(item).strip()
return None
def clean_custom_properties(custom_props):
"""
Remove null, empty, or invalid values from custom_properties dict.
Only keeps properties that have meaningful values.
"""
if not custom_props:
return None
cleaned = {}
for key, value in custom_props.items():
# Handle fields that should extract clean strings
if key in ['youtube_trailer', 'actors', 'director', 'cast']:
clean_value = extract_string_from_array_or_string(value)
if clean_value:
cleaned[key] = clean_value
# Handle backdrop_path which should remain as array format
elif key == 'backdrop_path':
clean_value = extract_string_from_array_or_string(value)
if clean_value:
cleaned[key] = [clean_value]
else:
# For other properties, keep them if they're not None and not empty
if value is not None and value != '' and value != []:
# If it's a list with only null values, skip it
if isinstance(value, list) and all(item is None for item in value):
continue
cleaned[key] = value
return cleaned if cleaned else None
def should_update_field(existing_value, new_value):
"""
Helper function to determine if we should update a field.
Returns True if:
- new_value is a non-empty string (or contains one if it's an array) AND
- existing_value is None, empty string, array with null/empty values, or non-string
"""
# Extract actual string values from arrays if needed
new_string = extract_string_from_array_or_string(new_value)
existing_string = extract_string_from_array_or_string(existing_value)
return new_string is not None and (existing_string is None or not existing_string)
@shared_task
def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
"""
Fetch advanced movie data from provider and update Movie and M3UMovieRelation.
Only fetch if last_advanced_refresh > 24h ago, unless force_refresh is True.
"""
try:
relation = M3UMovieRelation.objects.select_related('movie', 'm3u_account').get(id=m3u_movie_relation_id)
now = timezone.now()
if not force_refresh and relation.last_advanced_refresh and (now - relation.last_advanced_refresh).total_seconds() < 86400:
return "Advanced data recently fetched, skipping."
account = relation.m3u_account
movie = relation.movie
from core.xtream_codes import Client as XtreamCodesClient
with XtreamCodesClient(
server_url=account.server_url,
username=account.username,
password=account.password,
user_agent=account.get_user_agent().user_agent
) as client:
vod_info = client.get_vod_info(relation.stream_id)
if vod_info and 'info' in vod_info:
info_raw = vod_info.get('info', {})
# Handle case where 'info' might be a list instead of dict
if isinstance(info_raw, list):
# If it's a list, try to use the first item or create empty dict
info = info_raw[0] if info_raw and isinstance(info_raw[0], dict) else {}
logger.warning(f"VOD info for stream {relation.stream_id} returned list instead of dict, using first item")
elif isinstance(info_raw, dict):
info = info_raw
else:
info = {}
logger.warning(f"VOD info for stream {relation.stream_id} returned unexpected type: {type(info_raw)}")
movie_data_raw = vod_info.get('movie_data', {})
# Handle case where 'movie_data' might be a list instead of dict
if isinstance(movie_data_raw, list):
movie_data = movie_data_raw[0] if movie_data_raw and isinstance(movie_data_raw[0], dict) else {}
logger.warning(f"VOD movie_data for stream {relation.stream_id} returned list instead of dict, using first item")
elif isinstance(movie_data_raw, dict):
movie_data = movie_data_raw
else:
movie_data = {}
logger.warning(f"VOD movie_data for stream {relation.stream_id} returned unexpected type: {type(movie_data_raw)}")
# Update Movie fields if changed
updated = False
custom_props = movie.custom_properties or {}
if info.get('plot') and info.get('plot') != movie.description:
movie.description = info.get('plot')
updated = True
normalized_rating = normalize_rating(info.get('rating'))
if normalized_rating and normalized_rating != movie.rating:
movie.rating = normalized_rating
updated = True
if info.get('genre') and info.get('genre') != movie.genre:
movie.genre = info.get('genre')
updated = True
if info.get('duration_secs'):
duration_secs = int(info.get('duration_secs'))
if duration_secs != movie.duration_secs:
movie.duration_secs = duration_secs
updated = True
# Check for releasedate or release_date
release_date_value = info.get('releasedate') or info.get('release_date')
if release_date_value:
try:
year = int(str(release_date_value).split('-')[0])
if year != movie.year:
movie.year = year
updated = True
except Exception:
pass
# Handle TMDB/IMDB ID updates with duplicate key protection
tmdb_id_to_set = info.get('tmdb_id') if info.get('tmdb_id') and info.get('tmdb_id') != movie.tmdb_id else None
imdb_id_to_set = info.get('imdb_id') if info.get('imdb_id') and info.get('imdb_id') != movie.imdb_id else None
logger.debug(f"Movie {movie.id} current IDs: tmdb_id={movie.tmdb_id}, imdb_id={movie.imdb_id}")
logger.debug(f"IDs to set: tmdb_id={tmdb_id_to_set}, imdb_id={imdb_id_to_set}")
if tmdb_id_to_set or imdb_id_to_set:
# Check for existing movies with these IDs and handle duplicates
updated_movie, relation_updated = handle_movie_id_conflicts(
movie, relation, tmdb_id_to_set, imdb_id_to_set
)
if relation_updated:
# If the relation was updated to point to a different movie,
# we need to update our reference and continue with that movie
movie = updated_movie
logger.info(f"Relation updated, now working with movie {movie.id}")
else:
# No relation update, safe to set the IDs
if tmdb_id_to_set:
movie.tmdb_id = tmdb_id_to_set
updated = True
logger.debug(f"Set tmdb_id {tmdb_id_to_set} on movie {movie.id}")
if imdb_id_to_set:
movie.imdb_id = imdb_id_to_set
updated = True
logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {movie.id}")
if should_update_field(custom_props.get('youtube_trailer'), info.get('trailer')):
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('trailer'))
updated = True
if should_update_field(custom_props.get('youtube_trailer'), info.get('youtube_trailer')):
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('youtube_trailer'))
updated = True
if should_update_field(custom_props.get('backdrop_path'), info.get('backdrop_path')):
backdrop_url = extract_string_from_array_or_string(info.get('backdrop_path'))
custom_props['backdrop_path'] = [backdrop_url] if backdrop_url else None
updated = True
for actors_key in ('actors', 'cast'):
actors_raw = info.get(actors_key)
if should_update_field(custom_props.get('actors'), actors_raw):
if isinstance(actors_raw, list):
custom_props['actors'] = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
else:
custom_props['actors'] = extract_string_from_array_or_string(actors_raw)
updated = True
break
if should_update_field(custom_props.get('director'), info.get('director')):
custom_props['director'] = extract_string_from_array_or_string(info.get('director'))
updated = True
if updated:
# Clean custom_properties before saving to remove null/empty values
movie.custom_properties = clean_custom_properties(custom_props)
try:
movie.save()
except Exception as save_error:
# If we still get an integrity error after our conflict resolution,
# log it and try to save without the problematic IDs
logger.error(f"Failed to save movie {movie.id} after conflict resolution: {str(save_error)}")
if 'tmdb_id' in str(save_error) and movie.tmdb_id:
logger.warning(f"Clearing tmdb_id {movie.tmdb_id} from movie {movie.id} due to save error")
movie.tmdb_id = None
if 'imdb_id' in str(save_error) and movie.imdb_id:
logger.warning(f"Clearing imdb_id {movie.imdb_id} from movie {movie.id} due to save error")
movie.imdb_id = None
try:
movie.save()
logger.info(f"Successfully saved movie {movie.id} after clearing problematic IDs")
except Exception as final_error:
logger.error(f"Final save attempt failed for movie {movie.id}: {str(final_error)}")
raise
# Update relation custom_properties and last_advanced_refresh
relation_custom_props = relation.custom_properties or {}
# Clean the detailed_info before saving to avoid storing null/empty arrays
cleaned_info = clean_custom_properties(info) if info else None
cleaned_movie_data = clean_custom_properties(movie_data) if movie_data else None
if cleaned_info:
relation_custom_props['detailed_info'] = cleaned_info
if cleaned_movie_data:
relation_custom_props['movie_data'] = cleaned_movie_data
relation_custom_props['detailed_fetched'] = True
relation.custom_properties = relation_custom_props
relation.last_advanced_refresh = now
relation.save(update_fields=['custom_properties', 'last_advanced_refresh'])
return "Advanced data refreshed."
except Exception as e:
logger.error(f"Error refreshing advanced movie data for relation {m3u_movie_relation_id}: {str(e)}")
return f"Error: {str(e)}"