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
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:
@@ -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']
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
]
|
||||
Reference in New Issue
Block a user