mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 10:45:27 +00:00
- Created VODLogo model for movies/series, separate from Logo (channels only) - Added database migration to create vodlogo table and migrate existing VOD logos - Implemented VODLogoViewSet with pagination, filtering (used/unused/movies/series), and bulk operations - Built VODLogosTable component with server-side pagination matching channel logos styling - Added VOD logos tab with on-demand loading to Logos page - Fixed orphaned VOD content cleanup to always remove unused entries - Removed redundant channel_assignable filtering from channel logos
318 lines
14 KiB
Python
318 lines
14 KiB
Python
from django.db import models
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from apps.m3u.models import M3UAccount
|
|
import uuid
|
|
|
|
|
|
class VODLogo(models.Model):
|
|
"""Logo model specifically for VOD content (movies and series)"""
|
|
name = models.CharField(max_length=255)
|
|
url = models.TextField(unique=True)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
verbose_name = 'VOD Logo'
|
|
verbose_name_plural = 'VOD Logos'
|
|
|
|
|
|
class VODCategory(models.Model):
|
|
"""Categories for organizing VODs (e.g., Action, Comedy, Drama)"""
|
|
|
|
CATEGORY_TYPE_CHOICES = [
|
|
('movie', 'Movie'),
|
|
('series', 'Series'),
|
|
]
|
|
|
|
name = models.CharField(max_length=255)
|
|
category_type = models.CharField(
|
|
max_length=10,
|
|
choices=CATEGORY_TYPE_CHOICES,
|
|
default='movie',
|
|
help_text="Type of content this category contains"
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = 'VOD Category'
|
|
verbose_name_plural = 'VOD Categories'
|
|
ordering = ['name']
|
|
unique_together = [('name', 'category_type')]
|
|
|
|
@classmethod
|
|
def bulk_create_and_fetch(cls, objects, ignore_conflicts=False):
|
|
# Perform the bulk create operation
|
|
cls.objects.bulk_create(objects, ignore_conflicts=ignore_conflicts)
|
|
|
|
# Use the unique fields to fetch the created objects
|
|
# Since we have unique_together on ('name', 'category_type'), we need both fields
|
|
filter_conditions = []
|
|
for obj in objects:
|
|
filter_conditions.append(
|
|
Q(name=obj.name, category_type=obj.category_type)
|
|
)
|
|
|
|
if filter_conditions:
|
|
# Combine all conditions with OR
|
|
combined_condition = filter_conditions[0]
|
|
for condition in filter_conditions[1:]:
|
|
combined_condition |= condition
|
|
|
|
created_objects = cls.objects.filter(combined_condition)
|
|
else:
|
|
created_objects = cls.objects.none()
|
|
|
|
return created_objects
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.get_category_type_display()})"
|
|
|
|
|
|
class Series(models.Model):
|
|
"""Series information for TV shows"""
|
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(blank=True, null=True)
|
|
year = models.IntegerField(blank=True, null=True)
|
|
rating = models.CharField(max_length=10, blank=True, null=True)
|
|
genre = models.CharField(max_length=255, blank=True, null=True)
|
|
logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='series')
|
|
|
|
# Metadata IDs for deduplication - these should be globally unique when present
|
|
tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata")
|
|
imdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="IMDB ID for metadata")
|
|
|
|
# Additional metadata and properties
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text='Additional metadata and properties for the series')
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = 'Series'
|
|
verbose_name_plural = 'Series'
|
|
ordering = ['name']
|
|
# Only enforce name+year uniqueness when no external IDs are present
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=['name', 'year'],
|
|
condition=models.Q(tmdb_id__isnull=True) & models.Q(imdb_id__isnull=True),
|
|
name='unique_series_name_year_no_external_id'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
year_str = f" ({self.year})" if self.year else ""
|
|
return f"{self.name}{year_str}"
|
|
|
|
|
|
class Movie(models.Model):
|
|
"""Movie content"""
|
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(blank=True, null=True)
|
|
year = models.IntegerField(blank=True, null=True)
|
|
rating = models.CharField(max_length=10, blank=True, null=True)
|
|
genre = models.CharField(max_length=255, blank=True, null=True)
|
|
duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds")
|
|
logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='movie')
|
|
|
|
# Metadata IDs for deduplication - these should be globally unique when present
|
|
tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata")
|
|
imdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="IMDB ID for metadata")
|
|
|
|
# Additional metadata and properties
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text='Additional metadata and properties for the movie')
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = 'Movie'
|
|
verbose_name_plural = 'Movies'
|
|
ordering = ['name']
|
|
# Only enforce name+year uniqueness when no external IDs are present
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=['name', 'year'],
|
|
condition=models.Q(tmdb_id__isnull=True) & models.Q(imdb_id__isnull=True),
|
|
name='unique_movie_name_year_no_external_id'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
year_str = f" ({self.year})" if self.year else ""
|
|
return f"{self.name}{year_str}"
|
|
|
|
|
|
class Episode(models.Model):
|
|
"""Episode content for TV series"""
|
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(blank=True, null=True)
|
|
air_date = models.DateField(blank=True, null=True)
|
|
rating = models.CharField(max_length=10, blank=True, null=True)
|
|
duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds")
|
|
|
|
# Episode specific fields
|
|
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='episodes')
|
|
season_number = models.IntegerField(blank=True, null=True)
|
|
episode_number = models.IntegerField(blank=True, null=True)
|
|
|
|
# Metadata IDs
|
|
tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True)
|
|
imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True)
|
|
|
|
# Custom properties for episode
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text="Custom properties for this episode")
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = 'Episode'
|
|
verbose_name_plural = 'Episodes'
|
|
ordering = ['series__name', 'season_number', 'episode_number']
|
|
unique_together = [
|
|
('series', 'season_number', 'episode_number'),
|
|
]
|
|
|
|
def __str__(self):
|
|
season_ep = f"S{self.season_number or 0:02d}E{self.episode_number or 0:02d}"
|
|
return f"{self.series.name} - {season_ep} - {self.name}"
|
|
|
|
|
|
# New relation models to link M3U accounts with VOD content
|
|
|
|
class M3USeriesRelation(models.Model):
|
|
"""Links M3U accounts to Series with provider-specific information"""
|
|
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations')
|
|
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='m3u_relations')
|
|
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
|
|
|
# Provider-specific fields - renamed to avoid clash with series ForeignKey
|
|
external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider")
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data")
|
|
|
|
# Timestamps
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed")
|
|
last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan")
|
|
|
|
class Meta:
|
|
verbose_name = 'M3U Series Relation'
|
|
verbose_name_plural = 'M3U Series Relations'
|
|
unique_together = [('m3u_account', 'external_series_id')]
|
|
|
|
def __str__(self):
|
|
return f"{self.m3u_account.name} - {self.series.name}"
|
|
|
|
|
|
class M3UMovieRelation(models.Model):
|
|
"""Links M3U accounts to Movies with provider-specific information"""
|
|
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations')
|
|
movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations')
|
|
category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
|
|
|
# Streaming information (provider-specific)
|
|
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
|
container_extension = models.CharField(max_length=10, blank=True, null=True)
|
|
|
|
# Provider-specific data
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
|
|
|
|
# Timestamps
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
last_advanced_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time advanced data was fetched from provider")
|
|
last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan")
|
|
|
|
class Meta:
|
|
verbose_name = 'M3U Movie Relation'
|
|
verbose_name_plural = 'M3U Movie Relations'
|
|
unique_together = [('m3u_account', 'stream_id')]
|
|
|
|
def __str__(self):
|
|
return f"{self.m3u_account.name} - {self.movie.name}"
|
|
|
|
def get_stream_url(self):
|
|
"""Get the full stream URL for this movie from this provider"""
|
|
# Build URL dynamically for XtreamCodes accounts
|
|
if self.m3u_account.account_type == 'XC':
|
|
server_url = self.m3u_account.server_url.rstrip('/')
|
|
username = self.m3u_account.username
|
|
password = self.m3u_account.password
|
|
return f"{server_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
|
|
else:
|
|
# For other account types, we would need another way to build URLs
|
|
return None
|
|
|
|
|
|
class M3UEpisodeRelation(models.Model):
|
|
"""Links M3U accounts to Episodes with provider-specific information"""
|
|
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations')
|
|
episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations')
|
|
|
|
# Streaming information (provider-specific)
|
|
stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider")
|
|
container_extension = models.CharField(max_length=10, blank=True, null=True)
|
|
|
|
# Provider-specific data
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
|
|
|
|
# Timestamps
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan")
|
|
|
|
class Meta:
|
|
verbose_name = 'M3U Episode Relation'
|
|
verbose_name_plural = 'M3U Episode Relations'
|
|
unique_together = [('m3u_account', 'stream_id')]
|
|
|
|
def __str__(self):
|
|
return f"{self.m3u_account.name} - {self.episode}"
|
|
|
|
def get_stream_url(self):
|
|
"""Get the full stream URL for this episode from this provider"""
|
|
from core.xtream_codes import Client as XtreamCodesClient
|
|
|
|
if self.m3u_account.account_type == 'XC':
|
|
# For XtreamCodes accounts, build the URL dynamically
|
|
server_url = self.m3u_account.server_url.rstrip('/')
|
|
username = self.m3u_account.username
|
|
password = self.m3u_account.password
|
|
return f"{server_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
|
|
else:
|
|
# We might support non XC accounts in the future
|
|
# For now, return None
|
|
return None
|
|
|
|
class M3UVODCategoryRelation(models.Model):
|
|
"""Links M3U accounts to categories with provider-specific information"""
|
|
m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='category_relations')
|
|
category = models.ForeignKey(VODCategory, on_delete=models.CASCADE, related_name='m3u_relations')
|
|
|
|
enabled = models.BooleanField(
|
|
default=False, help_text="Set to false to deactivate this category for the M3U account"
|
|
)
|
|
|
|
custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.")
|
|
|
|
# Timestamps
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = 'M3U VOD Category Relation'
|
|
verbose_name_plural = 'M3U VOD Category Relations'
|
|
unique_together = [('m3u_account', 'category')]
|
|
|
|
def __str__(self):
|
|
return f"{self.m3u_account.name} - {self.category.name}"
|