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