Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
View File
+67
View File
@@ -0,0 +1,67 @@
from django.contrib import admin
from .models import (
Series, VODCategory, Movie, Episode,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
)
@admin.register(VODCategory)
class VODCategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'category_type', 'created_at']
list_filter = ['category_type', 'created_at']
search_fields = ['name']
@admin.register(Series)
class SeriesAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'created_at']
list_filter = ['year', 'created_at']
search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
readonly_fields = ['uuid', 'created_at', 'updated_at']
@admin.register(Movie)
class MovieAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'genre', 'duration_secs', 'created_at']
list_filter = ['year', 'created_at']
search_fields = ['name', 'description', 'tmdb_id', 'imdb_id']
readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request):
return super().get_queryset(request).select_related('logo')
@admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin):
list_display = ['name', 'series', 'season_number', 'episode_number', 'duration_secs', 'created_at']
list_filter = ['series', 'season_number', 'created_at']
search_fields = ['name', 'description', 'series__name']
readonly_fields = ['uuid', 'created_at', 'updated_at']
def get_queryset(self, request):
return super().get_queryset(request).select_related('series')
@admin.register(M3UMovieRelation)
class M3UMovieRelationAdmin(admin.ModelAdmin):
list_display = ['movie', 'm3u_account', 'category', 'stream_id', 'created_at']
list_filter = ['m3u_account', 'category', 'created_at']
search_fields = ['movie__name', 'm3u_account__name', 'stream_id']
readonly_fields = ['created_at', 'updated_at']
@admin.register(M3USeriesRelation)
class M3USeriesRelationAdmin(admin.ModelAdmin):
list_display = ['series', 'm3u_account', 'category', 'external_series_id', 'created_at']
list_filter = ['m3u_account', 'category', 'created_at']
search_fields = ['series__name', 'm3u_account__name', 'external_series_id']
readonly_fields = ['created_at', 'updated_at']
@admin.register(M3UEpisodeRelation)
class M3UEpisodeRelationAdmin(admin.ModelAdmin):
list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at']
list_filter = ['m3u_account', 'created_at']
search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id']
readonly_fields = ['created_at', 'updated_at']
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
MovieViewSet,
EpisodeViewSet,
SeriesViewSet,
VODCategoryViewSet,
UnifiedContentViewSet,
VODLogoViewSet,
)
app_name = 'vod'
router = DefaultRouter()
router.register(r'movies', MovieViewSet, basename='movie')
router.register(r'episodes', EpisodeViewSet, basename='episode')
router.register(r'series', SeriesViewSet, basename='series')
router.register(r'categories', VODCategoryViewSet, basename='vodcategory')
router.register(r'all', UnifiedContentViewSet, basename='unified-content')
router.register(r'vodlogos', VODLogoViewSet, basename='vodlogo')
urlpatterns = router.urls
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class VODConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.vod'
verbose_name = 'Video on Demand'
def ready(self):
"""Initialize VOD app when Django is ready"""
# Import models to ensure they're registered
from . import models
+149
View File
@@ -0,0 +1,149 @@
"""VOD catalog: optional single M3U account for library listings (env DISPATCHARR_VOD_CATALOG_ACCOUNT_ID).
Plus per-user playback / catalog policies stored in User.custom_properties:
custom_properties = {
"vod_playback_mode": "strict" | "prefer" | "auto",
"vod_playback_account_ids": [10, 11],
"allowed_movie_category_ids": [3, 7, 12], # empty/missing = all
"allowed_series_category_ids": [4, 9], # empty/missing = all
}
"""
import os
def get_vod_catalog_account_id():
raw = (os.environ.get("DISPATCHARR_VOD_CATALOG_ACCOUNT_ID") or "").strip()
if raw.isdigit():
return int(raw)
return None
def get_vod_playback_allowed_account_ids():
"""
Optional comma-separated M3U account IDs used only for VOD stream selection (failover).
Example: DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS=10,11,12
When unset, any active M3U account with a relation may be used (default).
"""
raw = (os.environ.get("DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS") or "").strip()
if not raw:
return None
out = []
for part in raw.split(","):
part = part.strip()
if part.isdigit():
out.append(int(part))
return out if out else None
def filter_m3u_relation_queryset_for_playback(qs):
"""
Apply DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS allowlist to an M3U relation queryset.
Used by VOD proxy and Xtream stream redirects so failover matches playback policy.
"""
allowed = get_vod_playback_allowed_account_ids()
if allowed is not None:
return qs.filter(m3u_account_id__in=allowed, m3u_account__is_active=True)
return qs.filter(m3u_account__is_active=True)
def _coerce_int_list(raw):
out = []
if not raw:
return out
if isinstance(raw, (list, tuple)):
items = raw
elif isinstance(raw, str):
items = raw.split(",")
else:
return out
for x in items:
try:
out.append(int(str(x).strip()))
except (TypeError, ValueError):
continue
return out
def get_user_vod_prefs(user):
"""Return a normalized dict of per-user VOD policies. Safe with None / anonymous user."""
if not user or not getattr(user, "is_authenticated", False):
return {
"playback_mode": "auto",
"playback_account_ids": [],
"allowed_movie_category_ids": None,
"allowed_series_category_ids": None,
}
cp = getattr(user, "custom_properties", None) or {}
accounts = _coerce_int_list(cp.get("vod_playback_account_ids"))
mode = (cp.get("vod_playback_mode") or "").strip().lower()
if mode not in ("strict", "prefer", "auto"):
mode = "auto"
movie_cats = _coerce_int_list(cp.get("allowed_movie_category_ids"))
series_cats = _coerce_int_list(cp.get("allowed_series_category_ids"))
return {
"playback_mode": mode,
"playback_account_ids": accounts,
"allowed_movie_category_ids": movie_cats or None,
"allowed_series_category_ids": series_cats or None,
}
def filter_relation_qs_for_user_playback(qs, user):
"""Restrict a relation queryset based on the user's playback prefs.
Returns (queryset, prefer_account_id).
- mode=strict: only the user's accounts (queryset further restricted).
- mode=prefer: queryset unchanged, returns the first account as preference for tie-breaking.
- mode=auto (or no accounts): no per-user filter; the global allowlist already applies upstream.
"""
prefs = get_user_vod_prefs(user)
accounts = prefs.get("playback_account_ids") or []
mode = prefs.get("playback_mode") or "auto"
if accounts and mode == "strict":
qs = qs.filter(m3u_account_id__in=accounts)
prefer = accounts[0]
elif accounts and mode == "prefer":
prefer = accounts[0]
else:
prefer = None
return qs, prefer
def get_user_allowed_movie_category_ids(user):
return get_user_vod_prefs(user).get("allowed_movie_category_ids")
def get_user_allowed_series_category_ids(user):
return get_user_vod_prefs(user).get("allowed_series_category_ids")
def filter_movie_qs_for_user(qs, user):
"""Restrict a Movie queryset to only categories the user is allowed to see."""
cats = get_user_allowed_movie_category_ids(user)
if cats:
return qs.filter(m3u_relations__category_id__in=cats).distinct()
return qs
def filter_series_qs_for_user(qs, user):
"""Restrict a Series queryset to only categories the user is allowed to see."""
cats = get_user_allowed_series_category_ids(user)
if cats:
return qs.filter(m3u_relations__category_id__in=cats).distinct()
return qs
def filter_m3u_movie_relation_qs_for_user(qs, user):
cats = get_user_allowed_movie_category_ids(user)
if cats:
return qs.filter(category_id__in=cats)
return qs
def filter_m3u_series_relation_qs_for_user(qs, user):
cats = get_user_allowed_series_category_ids(user)
if cats:
return qs.filter(category_id__in=cats)
return qs
+201
View File
@@ -0,0 +1,201 @@
# Generated by Django 5.2.4 on 2025-08-28 18:16
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('dispatcharr_channels', '0024_alter_channelgroupm3uaccount_channel_group'),
('m3u', '0016_m3uaccount_priority'),
]
operations = [
migrations.CreateModel(
name='Movie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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(blank=True, max_length=10, null=True)),
('genre', models.CharField(blank=True, max_length=255, null=True)),
('duration_secs', models.IntegerField(blank=True, help_text='Duration in seconds', null=True)),
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True, unique=True)),
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True, unique=True)),
('custom_properties', models.JSONField(blank=True, help_text='Additional metadata and properties for the movie', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='movie', to='dispatcharr_channels.logo')),
],
options={
'verbose_name': 'Movie',
'verbose_name_plural': 'Movies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Series',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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(blank=True, max_length=10, null=True)),
('genre', models.CharField(blank=True, max_length=255, null=True)),
('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True, unique=True)),
('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True, unique=True)),
('custom_properties', models.JSONField(blank=True, help_text='Additional metadata and properties for the series', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='series', to='dispatcharr_channels.logo')),
],
options={
'verbose_name': 'Series',
'verbose_name_plural': 'Series',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Episode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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(blank=True, max_length=10, null=True)),
('duration_secs', models.IntegerField(blank=True, help_text='Duration in seconds', null=True)),
('season_number', models.IntegerField(blank=True, null=True)),
('episode_number', models.IntegerField(blank=True, null=True)),
('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)),
('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='Custom properties for this episode', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')),
],
options={
'verbose_name': 'Episode',
'verbose_name_plural': 'Episodes',
'ordering': ['series__name', 'season_number', 'episode_number'],
},
),
migrations.CreateModel(
name='VODCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'VOD Category',
'verbose_name_plural': 'VOD Categories',
'ordering': ['name'],
'unique_together': {('name', 'category_type')},
},
),
migrations.CreateModel(
name='M3UVODCategoryRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enabled', models.BooleanField(default=False, help_text='Set to false to deactivate this category for the M3U account')),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_relations', to='m3u.m3uaccount')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.vodcategory')),
],
options={
'verbose_name': 'M3U VOD Category Relation',
'verbose_name_plural': 'M3U VOD Category Relations',
},
),
migrations.CreateModel(
name='M3USeriesRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_relations', to='m3u.m3uaccount')),
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.series')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'M3U Series Relation',
'verbose_name_plural': 'M3U Series Relations',
},
),
migrations.CreateModel(
name='M3UMovieRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_advanced_refresh', models.DateTimeField(blank=True, help_text='Last time advanced data was fetched from provider', null=True)),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_relations', to='m3u.m3uaccount')),
('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.movie')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')),
],
options={
'verbose_name': 'M3U Movie Relation',
'verbose_name_plural': 'M3U Movie Relations',
},
),
migrations.CreateModel(
name='M3UEpisodeRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)),
('container_extension', models.CharField(blank=True, max_length=10, null=True)),
('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.episode')),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='m3u.m3uaccount')),
],
options={
'verbose_name': 'M3U Episode Relation',
'verbose_name_plural': 'M3U Episode Relations',
'unique_together': {('m3u_account', 'stream_id')},
},
),
migrations.AddConstraint(
model_name='movie',
constraint=models.UniqueConstraint(condition=models.Q(('tmdb_id__isnull', True), ('imdb_id__isnull', True)), fields=('name', 'year'), name='unique_movie_name_year_no_external_id'),
),
migrations.AddConstraint(
model_name='series',
constraint=models.UniqueConstraint(condition=models.Q(('tmdb_id__isnull', True), ('imdb_id__isnull', True)), fields=('name', 'year'), name='unique_series_name_year_no_external_id'),
),
migrations.AlterUniqueTogether(
name='episode',
unique_together={('series', 'season_number', 'episode_number')},
),
migrations.AlterUniqueTogether(
name='m3uvodcategoryrelation',
unique_together={('m3u_account', 'category')},
),
migrations.AlterUniqueTogether(
name='m3useriesrelation',
unique_together={('m3u_account', 'external_series_id')},
),
migrations.AlterUniqueTogether(
name='m3umovierelation',
unique_together={('m3u_account', 'stream_id')},
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.4 on 2025-09-04 21:12
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vod', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='m3uepisoderelation',
name='last_seen',
field=models.DateTimeField(default=django.utils.timezone.now, help_text='Last time this relation was seen during VOD scan'),
),
migrations.AddField(
model_name='m3umovierelation',
name='last_seen',
field=models.DateTimeField(default=django.utils.timezone.now, help_text='Last time this relation was seen during VOD scan'),
),
migrations.AddField(
model_name='m3useriesrelation',
name='last_seen',
field=models.DateTimeField(default=django.utils.timezone.now, help_text='Last time this relation was seen during VOD scan'),
),
]
@@ -0,0 +1,264 @@
# Generated by Django 5.2.4 on 2025-11-06 23:01
import django.db.models.deletion
from django.db import migrations, models
def migrate_vod_logos_forward(apps, schema_editor):
"""
Migrate VOD logos from the Logo table to the new VODLogo table.
This copies all logos referenced by movies or series to VODLogo.
Uses pure SQL for maximum performance.
"""
from django.db import connection
print("\n" + "="*80)
print("Starting VOD logo migration...")
print("="*80)
with connection.cursor() as cursor:
# Step 1: Copy unique logos from Logo table to VODLogo table
# Only copy logos that are used by movies or series
print("Copying logos to VODLogo table...")
cursor.execute("""
INSERT INTO vod_vodlogo (name, url)
SELECT DISTINCT l.name, l.url
FROM dispatcharr_channels_logo l
WHERE l.id IN (
SELECT DISTINCT logo_id FROM vod_movie WHERE logo_id IS NOT NULL
UNION
SELECT DISTINCT logo_id FROM vod_series WHERE logo_id IS NOT NULL
)
ON CONFLICT (url) DO NOTHING
""")
print(f"Created VODLogo entries")
# Step 2: Update movies to point to VODLogo IDs using JOIN
print("Updating movie references...")
cursor.execute("""
UPDATE vod_movie m
SET logo_id = v.id
FROM dispatcharr_channels_logo l
INNER JOIN vod_vodlogo v ON l.url = v.url
WHERE m.logo_id = l.id
AND m.logo_id IS NOT NULL
""")
movie_count = cursor.rowcount
print(f"Updated {movie_count} movies with new VOD logo references")
# Step 3: Update series to point to VODLogo IDs using JOIN
print("Updating series references...")
cursor.execute("""
UPDATE vod_series s
SET logo_id = v.id
FROM dispatcharr_channels_logo l
INNER JOIN vod_vodlogo v ON l.url = v.url
WHERE s.logo_id = l.id
AND s.logo_id IS NOT NULL
""")
series_count = cursor.rowcount
print(f"Updated {series_count} series with new VOD logo references")
print("="*80)
print("VOD logo migration completed successfully!")
print(f"Summary: Updated {movie_count} movies and {series_count} series")
print("="*80 + "\n")
def migrate_vod_logos_backward(apps, schema_editor):
"""
Reverse migration - moves VODLogos back to Logo table.
This recreates Logo entries for all VODLogos and updates Movie/Series references.
"""
Logo = apps.get_model('dispatcharr_channels', 'Logo')
VODLogo = apps.get_model('vod', 'VODLogo')
Movie = apps.get_model('vod', 'Movie')
Series = apps.get_model('vod', 'Series')
print("\n" + "="*80)
print("REVERSE: Moving VOD logos back to Logo table...")
print("="*80)
# Get all VODLogos
vod_logos = VODLogo.objects.all()
print(f"Found {vod_logos.count()} VOD logos to reverse migrate")
# Create Logo entries for each VODLogo
logos_to_create = []
vod_to_logo_mapping = {} # VODLogo ID -> Logo ID
for vod_logo in vod_logos:
# Check if a Logo with this URL already exists
existing_logo = Logo.objects.filter(url=vod_logo.url).first()
if existing_logo:
# Logo already exists, just map to it
vod_to_logo_mapping[vod_logo.id] = existing_logo.id
print(f"Logo already exists for URL: {vod_logo.url[:50]}... (using existing)")
else:
# Create new Logo entry
new_logo = Logo(name=vod_logo.name, url=vod_logo.url)
logos_to_create.append(new_logo)
# Bulk create new Logo entries
if logos_to_create:
print(f"Creating {len(logos_to_create)} new Logo entries...")
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
print("Logo entries created")
# Get the created Logo instances with their IDs
for vod_logo in vod_logos:
if vod_logo.id not in vod_to_logo_mapping:
try:
logo = Logo.objects.get(url=vod_logo.url)
vod_to_logo_mapping[vod_logo.id] = logo.id
except Logo.DoesNotExist:
print(f"Warning: Could not find Logo for URL: {vod_logo.url[:100]}...")
print(f"Created mapping for {len(vod_to_logo_mapping)} VOD logos -> Logos")
# Update movies to point back to Logo table
movie_count = 0
for movie in Movie.objects.exclude(logo__isnull=True):
if movie.logo_id in vod_to_logo_mapping:
movie.logo_id = vod_to_logo_mapping[movie.logo_id]
movie.save(update_fields=['logo_id'])
movie_count += 1
print(f"Updated {movie_count} movies to use Logo table")
# Update series to point back to Logo table
series_count = 0
for series in Series.objects.exclude(logo__isnull=True):
if series.logo_id in vod_to_logo_mapping:
series.logo_id = vod_to_logo_mapping[series.logo_id]
series.save(update_fields=['logo_id'])
series_count += 1
print(f"Updated {series_count} series to use Logo table")
# Delete VODLogos (they're now redundant)
vod_logo_count = vod_logos.count()
vod_logos.delete()
print(f"Deleted {vod_logo_count} VOD logos")
print("="*80)
print("Reverse migration completed!")
print(f"Summary: Created/reused {len(vod_to_logo_mapping)} logos, updated {movie_count} movies and {series_count} series")
print("="*80 + "\n")
def cleanup_migrated_logos(apps, schema_editor):
"""
Delete Logo entries that were successfully migrated to VODLogo.
Uses efficient JOIN-based approach with LEFT JOIN to exclude channel usage.
"""
from django.db import connection
print("\n" + "="*80)
print("Cleaning up migrated Logo entries...")
print("="*80)
with connection.cursor() as cursor:
# Single efficient query using JOINs:
# - JOIN with vod_vodlogo to find migrated logos
# - LEFT JOIN with channels to find which aren't used
cursor.execute("""
DELETE FROM dispatcharr_channels_logo
WHERE id IN (
SELECT l.id
FROM dispatcharr_channels_logo l
INNER JOIN vod_vodlogo v ON l.url = v.url
LEFT JOIN dispatcharr_channels_channel c ON c.logo_id = l.id
WHERE c.id IS NULL
)
""")
deleted_count = cursor.rowcount
print(f"✓ Deleted {deleted_count} migrated Logo entries (not used by channels)")
print("="*80 + "\n")
class Migration(migrations.Migration):
dependencies = [
('vod', '0002_add_last_seen_with_default'),
('dispatcharr_channels', '0013_alter_logo_url'), # Ensure Logo table exists
]
operations = [
# Step 1: Create the VODLogo model
migrations.CreateModel(
name='VODLogo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('url', models.TextField(unique=True)),
],
options={
'verbose_name': 'VOD Logo',
'verbose_name_plural': 'VOD Logos',
},
),
# Step 2: Remove foreign key constraints temporarily (so we can change the IDs)
# We need to find and drop the actual constraint names dynamically
migrations.RunSQL(
sql=[
# Drop movie logo constraint (find it dynamically)
"""
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT conname INTO constraint_name
FROM pg_constraint
WHERE conrelid = 'vod_movie'::regclass
AND conname LIKE '%logo_id%fk%';
IF constraint_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE vod_movie DROP CONSTRAINT ' || constraint_name;
END IF;
END $$;
""",
# Drop series logo constraint (find it dynamically)
"""
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT conname INTO constraint_name
FROM pg_constraint
WHERE conrelid = 'vod_series'::regclass
AND conname LIKE '%logo_id%fk%';
IF constraint_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE vod_series DROP CONSTRAINT ' || constraint_name;
END IF;
END $$;
""",
],
reverse_sql=[
# The AlterField operations will recreate the constraints pointing to VODLogo,
# so we don't need to manually recreate them in reverse
migrations.RunSQL.noop,
],
),
# Step 3: Migrate the data (this copies logos and updates references)
migrations.RunPython(migrate_vod_logos_forward, migrate_vod_logos_backward),
# Step 4: Now we can safely alter the foreign keys to point to VODLogo
migrations.AlterField(
model_name='movie',
name='logo',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='movie', to='vod.vodlogo'),
),
migrations.AlterField(
model_name='series',
name='logo',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='series', to='vod.vodlogo'),
),
# Step 5: Clean up migrated Logo entries
migrations.RunPython(cleanup_migrated_logos, migrations.RunPython.noop),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.2.11 on 2026-02-24 23:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'),
]
operations = [
migrations.AddField(
model_name='m3uepisoderelation',
name='series_relation',
field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'),
),
]
View File
+361
View File
@@ -0,0 +1,361 @@
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 os
import uuid
from urllib.parse import quote
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}"
def _jellyfin_direct_stream_url(m3u_account, stream_id: str, container_extension: str | None) -> str | None:
"""URL reproducción directa Jellyfin (Static=true) para cuentas vod_backend=jellyfin."""
cp = m3u_account.custom_properties or {}
if cp.get("vod_backend") != "jellyfin":
return None
base = (m3u_account.server_url or "").rstrip("/")
if not base:
return None
api_key = (
os.environ.get("DISPATCHARR_JELLYFIN_API_KEY")
or cp.get("jellyfin_api_key")
or os.environ.get("JELLYFIN_API_KEY")
or os.environ.get("JELLYFIN_SYNC_API_KEY")
)
if not api_key:
return None
raw = (container_extension or "mkv").strip().lstrip(".").lower()
ext = (raw[:10] if raw else "mkv") or "mkv"
item_id = stream_id.strip()
if not item_id:
return None
key_q = quote(str(api_key), safe="")
return f"{base}/Videos/{item_id}/stream.{ext}?Static=true&api_key={key_q}"
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':
from core.xtream_codes import Client as XCClient
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
username = self.m3u_account.username
password = self.m3u_account.password
return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
jf = _jellyfin_direct_stream_url(self.m3u_account, self.stream_id, self.container_extension)
if jf:
return jf
# 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')
series_relation = models.ForeignKey(
'M3USeriesRelation',
on_delete=models.CASCADE,
related_name='episode_relations',
null=True,
blank=True,
help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed."
)
# 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
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
username = self.m3u_account.username
password = self.m3u_account.password
return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
jf = _jellyfin_direct_stream_url(self.m3u_account, self.stream_id, self.container_extension)
if jf:
return jf
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}"
@@ -0,0 +1,331 @@
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':
from core.xtream_codes import Client as XCClient
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
username = self.m3u_account.username
password = self.m3u_account.password
return f"{normalized_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')
series_relation = models.ForeignKey(
'M3USeriesRelation',
on_delete=models.CASCADE,
related_name='episode_relations',
null=True,
blank=True,
help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed."
)
# 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
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
username = self.m3u_account.username
password = self.m3u_account.password
return f"{normalized_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}"
+304
View File
@@ -0,0 +1,304 @@
from rest_framework import serializers
from django.urls import reverse
from .models import (
Series, VODCategory, Movie, Episode, VODLogo,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation
)
from apps.m3u.serializers import M3UAccountSerializer
class VODLogoSerializer(serializers.ModelSerializer):
cache_url = serializers.SerializerMethodField()
movie_count = serializers.SerializerMethodField()
series_count = serializers.SerializerMethodField()
is_used = serializers.SerializerMethodField()
item_names = serializers.SerializerMethodField()
class Meta:
model = VODLogo
fields = ["id", "name", "url", "cache_url", "movie_count", "series_count", "is_used", "item_names"]
def validate_url(self, value):
"""Validate that the URL is unique for creation or update"""
if self.instance and self.instance.url == value:
return value
if VODLogo.objects.filter(url=value).exists():
raise serializers.ValidationError("A VOD logo with this URL already exists.")
return value
def create(self, validated_data):
"""Handle logo creation with proper URL validation"""
return VODLogo.objects.create(**validated_data)
def update(self, instance, validated_data):
"""Handle logo updates"""
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
def get_cache_url(self, obj):
request = self.context.get("request")
if request:
return request.build_absolute_uri(
reverse("api:vod:vodlogo-cache", args=[obj.id])
)
return reverse("api:vod:vodlogo-cache", args=[obj.id])
def get_movie_count(self, obj):
"""Get the number of movies using this logo"""
return obj.movie.count() if hasattr(obj, 'movie') else 0
def get_series_count(self, obj):
"""Get the number of series using this logo"""
return obj.series.count() if hasattr(obj, 'series') else 0
def get_is_used(self, obj):
"""Check if this logo is used by any movies or series"""
return (hasattr(obj, 'movie') and obj.movie.exists()) or (hasattr(obj, 'series') and obj.series.exists())
def get_item_names(self, obj):
"""Get the list of movies and series using this logo"""
names = []
if hasattr(obj, 'movie'):
for movie in obj.movie.all()[:10]: # Limit to 10 items for performance
names.append(f"Movie: {movie.name}")
if hasattr(obj, 'series'):
for series in obj.series.all()[:10]: # Limit to 10 items for performance
names.append(f"Series: {series.name}")
return names
class M3UVODCategoryRelationSerializer(serializers.ModelSerializer):
category = serializers.IntegerField(source="category.id")
m3u_account = serializers.IntegerField(source="m3u_account.id")
class Meta:
model = M3UVODCategoryRelation
fields = ["category", "m3u_account", "enabled"]
class VODCategorySerializer(serializers.ModelSerializer):
category_type_display = serializers.CharField(source='get_category_type_display', read_only=True)
m3u_accounts = M3UVODCategoryRelationSerializer(many=True, source="m3u_relations", read_only=True)
class Meta:
model = VODCategory
fields = [
"id",
"name",
"category_type",
"category_type_display",
"m3u_accounts",
]
class SeriesSerializer(serializers.ModelSerializer):
logo = VODLogoSerializer(read_only=True)
episode_count = serializers.SerializerMethodField()
class Meta:
model = Series
fields = '__all__'
def get_episode_count(self, obj):
return obj.episodes.count()
class MovieSerializer(serializers.ModelSerializer):
logo = VODLogoSerializer(read_only=True)
class Meta:
model = Movie
fields = '__all__'
class EpisodeSerializer(serializers.ModelSerializer):
series = SeriesSerializer(read_only=True)
class Meta:
model = Episode
fields = '__all__'
class M3USeriesRelationSerializer(serializers.ModelSerializer):
series = SeriesSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
class Meta:
model = M3USeriesRelation
fields = '__all__'
class M3UMovieRelationSerializer(serializers.ModelSerializer):
movie = MovieSerializer(read_only=True)
category = VODCategorySerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
quality_info = serializers.SerializerMethodField()
class Meta:
model = M3UMovieRelation
fields = '__all__'
def get_quality_info(self, obj):
"""Extract quality information from various sources"""
quality_info = {}
# 1. Check custom_properties first
if obj.custom_properties:
if obj.custom_properties.get('quality'):
quality_info['quality'] = obj.custom_properties['quality']
return quality_info
elif obj.custom_properties.get('resolution'):
quality_info['resolution'] = obj.custom_properties['resolution']
return quality_info
# 2. Try to get detailed info from the movie if available
movie = obj.movie
if hasattr(movie, 'video') and movie.video:
video_data = movie.video
if isinstance(video_data, dict) and 'width' in video_data and 'height' in video_data:
width = video_data['width']
height = video_data['height']
quality_info['resolution'] = f"{width}x{height}"
# Convert to common quality names (prioritize width for ultrawide/cinematic content)
if width >= 3840:
quality_info['quality'] = '4K'
elif width >= 1920:
quality_info['quality'] = '1080p'
elif width >= 1280:
quality_info['quality'] = '720p'
elif width >= 854:
quality_info['quality'] = '480p'
else:
quality_info['quality'] = f"{width}x{height}"
return quality_info
# 3. Extract from movie name/title
if movie and movie.name:
name = movie.name
if '4K' in name or '2160p' in name:
quality_info['quality'] = '4K'
return quality_info
elif '1080p' in name or 'FHD' in name:
quality_info['quality'] = '1080p'
return quality_info
elif '720p' in name or 'HD' in name:
quality_info['quality'] = '720p'
return quality_info
elif '480p' in name:
quality_info['quality'] = '480p'
return quality_info
# 4. Try bitrate as last resort
if hasattr(movie, 'bitrate') and movie.bitrate and movie.bitrate > 0:
bitrate = movie.bitrate
if bitrate >= 6000:
quality_info['quality'] = '4K'
elif bitrate >= 3000:
quality_info['quality'] = '1080p'
elif bitrate >= 1500:
quality_info['quality'] = '720p'
else:
quality_info['bitrate'] = f"{round(bitrate/1000)}Mbps"
return quality_info
# 5. Fallback - no quality info available
return None
class M3UEpisodeRelationSerializer(serializers.ModelSerializer):
episode = EpisodeSerializer(read_only=True)
m3u_account = M3UAccountSerializer(read_only=True)
quality_info = serializers.SerializerMethodField()
class Meta:
model = M3UEpisodeRelation
fields = '__all__'
def get_quality_info(self, obj):
"""Extract quality information from various sources"""
quality_info = {}
# 1. Check custom_properties first
if obj.custom_properties:
if obj.custom_properties.get('quality'):
quality_info['quality'] = obj.custom_properties['quality']
return quality_info
elif obj.custom_properties.get('resolution'):
quality_info['resolution'] = obj.custom_properties['resolution']
return quality_info
# 2. Try to get detailed info from the episode if available
episode = obj.episode
if hasattr(episode, 'video') and episode.video:
video_data = episode.video
if isinstance(video_data, dict) and 'width' in video_data and 'height' in video_data:
width = video_data['width']
height = video_data['height']
quality_info['resolution'] = f"{width}x{height}"
# Convert to common quality names (prioritize width for ultrawide/cinematic content)
if width >= 3840:
quality_info['quality'] = '4K'
elif width >= 1920:
quality_info['quality'] = '1080p'
elif width >= 1280:
quality_info['quality'] = '720p'
elif width >= 854:
quality_info['quality'] = '480p'
else:
quality_info['quality'] = f"{width}x{height}"
return quality_info
# 3. Extract from episode name/title
if episode and episode.name:
name = episode.name
if '4K' in name or '2160p' in name:
quality_info['quality'] = '4K'
return quality_info
elif '1080p' in name or 'FHD' in name:
quality_info['quality'] = '1080p'
return quality_info
elif '720p' in name or 'HD' in name:
quality_info['quality'] = '720p'
return quality_info
elif '480p' in name:
quality_info['quality'] = '480p'
return quality_info
# 4. Try bitrate as last resort
if hasattr(episode, 'bitrate') and episode.bitrate and episode.bitrate > 0:
bitrate = episode.bitrate
if bitrate >= 6000:
quality_info['quality'] = '4K'
elif bitrate >= 3000:
quality_info['quality'] = '1080p'
elif bitrate >= 1500:
quality_info['quality'] = '720p'
else:
quality_info['bitrate'] = f"{round(bitrate/1000)}Mbps"
return quality_info
# 5. Fallback - no quality info available
return None
class EnhancedSeriesSerializer(serializers.ModelSerializer):
"""Enhanced serializer for series with provider information"""
logo = VODLogoSerializer(read_only=True)
providers = M3USeriesRelationSerializer(source='m3u_relations', many=True, read_only=True)
episode_count = serializers.SerializerMethodField()
class Meta:
model = Series
fields = '__all__'
def get_episode_count(self, obj):
return obj.episodes.count()
+2224
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, UnifiedContentViewSet
app_name = 'vod'
router = DefaultRouter()
router.register(r'movies', MovieViewSet)
router.register(r'episodes', EpisodeViewSet)
router.register(r'series', SeriesViewSet)
router.register(r'categories', VODCategoryViewSet)
router.register(r'all', UnifiedContentViewSet, basename='unified-content')
urlpatterns = [
path('api/', include(router.urls)),
]