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
+77
View File
@@ -0,0 +1,77 @@
# Generated by Django 5.1.6 on 2025-03-05 22:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0001_initial'),
('m3u', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ChannelGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
],
),
migrations.CreateModel(
name='Channel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_number', models.IntegerField()),
('channel_name', models.CharField(max_length=255)),
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
('logo_file', models.ImageField(blank=True, null=True, upload_to='logos/')),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('tvg_name', models.CharField(blank=True, max_length=255, null=True)),
('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='core.streamprofile')),
('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.channelgroup')),
],
),
migrations.CreateModel(
name='Stream',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default Stream', max_length=255)),
('url', models.URLField()),
('custom_url', models.URLField(blank=True, max_length=2000, null=True)),
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('local_file', models.FileField(blank=True, null=True, upload_to='uploads/')),
('current_viewers', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('group_name', models.CharField(blank=True, max_length=255, null=True)),
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='m3u.m3uaccount')),
('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='core.streamprofile')),
],
options={
'verbose_name': 'Stream',
'verbose_name_plural': 'Streams',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='ChannelStream',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=0)),
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')),
('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.stream')),
],
options={
'ordering': ['order'],
},
),
migrations.AddField(
model_name='channel',
name='streams',
field=models.ManyToManyField(blank=True, related_name='channels', through='dispatcharr_channels.ChannelStream', to='dispatcharr_channels.stream'),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-16 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='channel',
old_name='channel_name',
new_name='name',
),
migrations.RemoveField(
model_name='stream',
name='url',
),
migrations.RenameField(
model_name='stream',
old_name='custom_url',
new_name='url',
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.6 on 2025-03-16 13:25
import uuid
from django.db import migrations, models
# Generated by Django 5.1.6 on 2025-03-16 13:25
import uuid
from django.db import migrations, models
def generate_uuids(apps, schema_editor):
Channel = apps.get_model('dispatcharr_channels', 'Channel')
for channel in Channel.objects.all():
if not channel.uuid:
channel.uuid = uuid.uuid4()
channel.save()
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0002_rename_channel_name_channel_name_and_more'),
]
operations = [
migrations.AddField(
model_name='channel',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False),
),
migrations.RunPython(generate_uuids),
migrations.AlterField(
model_name='channel',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-17 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0003_channel_uuid'),
]
operations = [
migrations.AddField(
model_name='stream',
name='is_custom',
field=models.BooleanField(default=False, help_text='Whether this is a user-created stream or from an M3U account'),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 5.1.6 on 2025-03-19 16:33
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0004_stream_is_custom'),
('m3u', '0003_create_custom_account'),
]
operations = [
migrations.AddField(
model_name='stream',
name='channel_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='dispatcharr_channels.channelgroup'),
),
migrations.AddField(
model_name='stream',
name='last_seen',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='channel',
name='uuid',
field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
),
migrations.CreateModel(
name='ChannelGroupM3UAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enabled', models.BooleanField(default=True)),
('channel_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_account', to='dispatcharr_channels.channelgroup')),
('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='channel_group', to='m3u.m3uaccount')),
],
options={
'unique_together': {('channel_group', 'm3u_account')},
},
),
]
@@ -0,0 +1,51 @@
# In your app's migrations folder, create a new migration file
# e.g., migrations/000X_migrate_channel_group_to_foreign_key.py
from django.db import migrations
def migrate_channel_group(apps, schema_editor):
Stream = apps.get_model('dispatcharr_channels', 'Stream')
ChannelGroup = apps.get_model('dispatcharr_channels', 'ChannelGroup')
ChannelGroupM3UAccount = apps.get_model('dispatcharr_channels', 'ChannelGroup')
M3UAccount = apps.get_model('m3u', 'M3UAccount')
streams_to_update = []
for stream in Stream.objects.all():
# If the stream has a 'channel_group' string, try to find or create the ChannelGroup
if stream.group_name: # group_name holds the channel group string
channel_group_name = stream.group_name.strip()
# Try to find the ChannelGroup by name
channel_group, created = ChannelGroup.objects.get_or_create(name=channel_group_name)
# Set the foreign key to the found or newly created ChannelGroup
stream.channel_group = channel_group
streams_to_update.append(stream)
# If the stream has an M3U account, ensure the M3U account is linked
if stream.m3u_account:
ChannelGroupM3UAccount.objects.get_or_create(
channel_group=channel_group,
m3u_account=stream.m3u_account,
enabled=True # Or set it to whatever the default logic is
)
Stream.objects.bulk_update(streams_to_update, ['channel_group'])
def reverse_migration(apps, schema_editor):
# This reverse migration would undo the changes, setting `channel_group` to `None` and clearing any relationships.
Stream = apps.get_model('yourapp', 'Stream')
for stream in Stream.objects.all():
stream.channel_group = None
stream.save()
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0005_stream_channel_group_stream_last_seen_and_more'),
]
operations = [
migrations.RunPython(migrate_channel_group, reverse_code=reverse_migration),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-03-19 16:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0006_migrate_stream_groups'),
]
operations = [
migrations.RemoveField(
model_name='stream',
name='group_name',
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-19 18:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0007_remove_stream_group_name'),
]
operations = [
migrations.AddField(
model_name='stream',
name='stream_hash',
field=models.CharField(db_index=True, help_text='Unique hash for this stream from the M3U account', max_length=255, null=True, unique=True),
),
migrations.AlterField(
model_name='stream',
name='logo_url',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-26 12:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0008_stream_stream_hash'),
('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'),
]
operations = [
migrations.RemoveField(
model_name='channel',
name='tvg_name',
),
migrations.AddField(
model_name='channel',
name='epg_data',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='epg.epgdata'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-04-01 17:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0009_remove_channel_tvg_name_channel_epg_data'),
]
operations = [
migrations.AddField(
model_name='stream',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 5.1.6 on 2025-04-01 22:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0010_stream_custom_properties'),
]
operations = [
migrations.CreateModel(
name='Logo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('url', models.URLField(unique=True)),
],
),
migrations.RemoveField(
model_name='channel',
name='logo_file',
),
migrations.RemoveField(
model_name='channel',
name='logo_url',
),
migrations.AddField(
model_name='channel',
name='logo',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.logo'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.6 on 2025-04-02 23:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0011_logo_remove_channel_logo_file_and_more'),
]
operations = [
migrations.CreateModel(
name='ChannelProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
],
),
migrations.CreateModel(
name='ChannelProfileMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enabled', models.BooleanField(default=True)),
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')),
('channel_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channelprofile')),
],
options={
'unique_together': {('channel_profile', 'channel')},
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-04-04 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0012_channelprofile_channelprofilemembership'),
]
operations = [
migrations.AlterField(
model_name='logo',
name='url',
field=models.TextField(unique=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-04-05 22:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0013_alter_logo_url'),
]
operations = [
migrations.CreateModel(
name='Recording',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.DateTimeField()),
('end_time', models.DateTimeField()),
('task_id', models.CharField(blank=True, max_length=255, null=True)),
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='dispatcharr_channels.channel')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-04-07 16:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0014_recording'),
]
operations = [
migrations.AddField(
model_name='recording',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.6 on 2025-04-18 16:21
from django.db import migrations, models
from django.db.models import Count
def remove_duplicate_channel_streams(apps, schema_editor):
ChannelStream = apps.get_model('dispatcharr_channels', 'ChannelStream')
# Find duplicates by (channel, stream)
duplicates = (
ChannelStream.objects
.values('channel', 'stream')
.annotate(count=Count('id'))
.filter(count__gt=1)
)
for dupe in duplicates:
# Get all duplicates for this pair
dups = ChannelStream.objects.filter(
channel=dupe['channel'],
stream=dupe['stream']
).order_by('id')
# Keep the first one, delete the rest
dups.exclude(id=dups.first().id).delete()
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0015_recording_custom_properties'),
]
operations = [
migrations.RunPython(remove_duplicate_channel_streams),
migrations.AddConstraint(
model_name='channelstream',
constraint=models.UniqueConstraint(fields=('channel', 'stream'), name='unique_channel_stream'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-04-21 20:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0016_channelstream_unique_channel_stream'),
]
operations = [
migrations.AlterField(
model_name='channelgroup',
name='name',
field=models.TextField(db_index=True, unique=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-04-27 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0017_alter_channelgroup_name'),
]
operations = [
migrations.AddField(
model_name='channelgroupm3uaccount',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-04 00:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0018_channelgroupm3uaccount_custom_properties_and_more'),
]
operations = [
migrations.AddField(
model_name='channel',
name='tvc_guide_stationid',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-15 19:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0019_channel_tvc_guide_stationid'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='channel_number',
field=models.FloatField(db_index=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-18 14:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0020_alter_channel_channel_number'),
]
operations = [
migrations.AddField(
model_name='channel',
name='user_level',
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 5.1.6 on 2025-07-13 23:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0021_channel_user_level'),
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.AddField(
model_name='channel',
name='auto_created',
field=models.BooleanField(default=False, help_text='Whether this channel was automatically created via M3U auto channel sync'),
),
migrations.AddField(
model_name='channel',
name='auto_created_by',
field=models.ForeignKey(blank=True, help_text='The M3U account that auto-created this channel', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_created_channels', to='m3u.m3uaccount'),
),
migrations.AddField(
model_name='channelgroupm3uaccount',
name='auto_channel_sync',
field=models.BooleanField(default=False, help_text='Automatically create/delete channels to match streams in this group'),
),
migrations.AddField(
model_name='channelgroupm3uaccount',
name='auto_sync_channel_start',
field=models.FloatField(blank=True, help_text='Starting channel number for auto-created channels in this group', null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-07-29 02:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0022_channel_auto_created_channel_auto_created_by_and_more'),
]
operations = [
migrations.AddField(
model_name='stream',
name='stream_stats',
field=models.JSONField(blank=True, help_text='JSON object containing stream statistics like video codec, resolution, etc.', null=True),
),
migrations.AddField(
model_name='stream',
name='stream_stats_updated_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='When stream statistics were last updated', null=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-08-22 20:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
]
operations = [
migrations.AlterField(
model_name='channelgroupm3uaccount',
name='channel_group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_accounts', to='dispatcharr_channels.channelgroup'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-09-02 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0024_alter_channelgroupm3uaccount_channel_group'),
]
operations = [
migrations.AlterField(
model_name='channelgroupm3uaccount',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AlterField(
model_name='recording',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AlterField(
model_name='stream',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.0.14 on 2025-09-18 14:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0025_alter_channelgroupm3uaccount_custom_properties_and_more'),
]
operations = [
migrations.CreateModel(
name='RecurringRecordingRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('days_of_week', models.JSONField(default=list)),
('start_time', models.TimeField()),
('end_time', models.TimeField()),
('enabled', models.BooleanField(default=True)),
('name', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_rules', to='dispatcharr_channels.channel')),
],
options={
'ordering': ['channel', 'start_time'],
},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-10-05 20:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0026_recurringrecordingrule'),
]
operations = [
migrations.AddField(
model_name='recurringrecordingrule',
name='end_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='recurringrecordingrule',
name='start_date',
field=models.DateField(blank=True, null=True),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2025-10-06 22:55
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0027_recurringrecordingrule_end_date_and_more'),
]
operations = [
migrations.AddField(
model_name='channel',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Timestamp when this channel was created'),
preserve_default=False,
),
migrations.AddField(
model_name='channel',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this channel was last updated'),
),
]
@@ -0,0 +1,54 @@
# Generated migration to backfill stream_hash for existing custom streams
from django.db import migrations
import hashlib
def backfill_custom_stream_hashes(apps, schema_editor):
"""
Generate stream_hash for all custom streams that don't have one.
Uses stream ID to create a stable hash that won't change when name/url is edited.
"""
Stream = apps.get_model('dispatcharr_channels', 'Stream')
custom_streams_without_hash = Stream.objects.filter(
is_custom=True,
stream_hash__isnull=True
)
updated_count = 0
for stream in custom_streams_without_hash:
# Generate a stable hash using the stream's ID
# This ensures the hash never changes even if name/url is edited
unique_string = f"custom_stream_{stream.id}"
stream.stream_hash = hashlib.sha256(unique_string.encode()).hexdigest()
stream.save(update_fields=['stream_hash'])
updated_count += 1
if updated_count > 0:
print(f"Backfilled stream_hash for {updated_count} custom streams")
else:
print("No custom streams needed stream_hash backfill")
def reverse_backfill(apps, schema_editor):
"""
Reverse migration - clear stream_hash for custom streams.
Note: This will break preview functionality for custom streams.
"""
Stream = apps.get_model('dispatcharr_channels', 'Stream')
custom_streams = Stream.objects.filter(is_custom=True)
count = custom_streams.update(stream_hash=None)
print(f"Cleared stream_hash for {count} custom streams")
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0028_channel_created_at_channel_updated_at'),
]
operations = [
migrations.RunPython(backfill_custom_stream_hashes, reverse_backfill),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-28 20:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0029_backfill_custom_stream_hashes'),
]
operations = [
migrations.AlterField(
model_name='stream',
name='url',
field=models.URLField(blank=True, max_length=4096, null=True),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-09 18:19
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0030_alter_stream_url'),
]
operations = [
migrations.AddField(
model_name='channelgroupm3uaccount',
name='is_stale',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'),
),
migrations.AddField(
model_name='channelgroupm3uaccount',
name='last_seen',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Last time this group was seen in the M3U source during a refresh'),
),
migrations.AddField(
model_name='stream',
name='is_stale',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream is stale (not seen in recent refresh, pending deletion)'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.9 on 2026-01-17 16:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0031_channelgroupm3uaccount_is_stale_and_more'),
]
operations = [
migrations.AddField(
model_name='channel',
name='is_adult',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this channel contains adult content'),
),
migrations.AddField(
model_name='stream',
name='is_adult',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream contains adult content'),
),
]
@@ -0,0 +1,205 @@
# Generated by Django - Add stream_id and channel_number fields with data migration
from django.db import migrations, models
import hashlib
import json
import logging
logger = logging.getLogger(__name__)
def populate_fields_and_rehash(apps, schema_editor):
"""
Populate stream_id and stream_chno from custom_properties for XC account streams,
populate stream_chno from tvg-chno for standard M3U accounts,
then rehash XC streams using stable hash keys.
"""
Stream = apps.get_model('dispatcharr_channels', 'Stream')
M3UAccount = apps.get_model('m3u', 'M3UAccount')
CoreSettings = apps.get_model('core', 'CoreSettings')
# Get hash keys from settings
try:
stream_settings = CoreSettings.objects.get(key='stream_settings')
hash_key_str = stream_settings.value.get('m3u_hash_key', '') if stream_settings.value else ''
keys = [k.strip() for k in hash_key_str.split(',') if k.strip()] if hash_key_str else []
except CoreSettings.DoesNotExist:
keys = []
logger.info(f"Using hash keys: {keys}")
# Get XC account IDs
xc_account_ids = set(
M3UAccount.objects.filter(account_type='XC').values_list('id', flat=True)
)
logger.info(f"Found {len(xc_account_ids)} XC accounts")
# Track hash collisions for XC streams
hash_map = {} # new_hash -> stream_id
duplicates_to_delete = []
# Process all streams in batches
batch_size = 1000
processed = 0
updated = 0
total_count = Stream.objects.count()
logger.info(f"Processing {total_count} total streams")
streams_to_update = []
for stream in Stream.objects.select_related('channel_group', 'm3u_account').iterator(chunk_size=batch_size):
processed += 1
needs_update = False
custom_props = stream.custom_properties or {}
is_xc = stream.m3u_account_id in xc_account_ids if stream.m3u_account_id else False
# Extract stream_id (XC accounts only)
if is_xc and isinstance(custom_props, dict):
provider_stream_id = custom_props.get('stream_id')
if provider_stream_id:
try:
stream.stream_id = int(provider_stream_id)
needs_update = True
except (ValueError, TypeError):
pass
# Extract stream_chno
channel_num = None
if isinstance(custom_props, dict):
if is_xc:
# XC accounts use 'num'
channel_num = custom_props.get('num')
else:
# Standard M3U accounts use 'tvg-chno' or 'channel-number' (case insensitive check)
for key in ['tvg-chno', 'TVG-CHNO', 'tvg-Chno', 'Tvg-Chno', 'channel-number', 'Channel-Number', 'CHANNEL-NUMBER']:
if key in custom_props:
channel_num = custom_props.get(key)
break
if channel_num is not None:
try:
stream.stream_chno = float(channel_num)
needs_update = True
except (ValueError, TypeError):
pass
# Rehash XC streams only when 'url' is in hash keys (otherwise hash wouldn't change)
if is_xc and stream.stream_id and keys and 'url' in keys:
# For XC accounts, use stream_id instead of url when 'url' is in the hash keys
# This ensures credential/URL changes don't break stream identity
effective_url = stream.stream_id
# Get group name
group_name = stream.channel_group.name if stream.channel_group else None
# Build hash parts
stream_parts = {
"name": stream.name,
"url": effective_url,
"tvg_id": stream.tvg_id,
"m3u_id": stream.m3u_account_id,
"group": group_name
}
hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
# When using stream_id instead of URL, we MUST include m3u_id to prevent
# collisions across different XC accounts (stream_id is only unique per account)
if 'm3u_id' not in hash_parts:
hash_parts['m3u_id'] = stream.m3u_account_id
# Generate hash
serialized_obj = json.dumps(hash_parts, sort_keys=True)
new_hash = hashlib.sha256(serialized_obj.encode()).hexdigest()
# Check for collisions
if new_hash in hash_map:
# Duplicate - mark for deletion (keep the first one)
duplicates_to_delete.append(stream.id)
continue
hash_map[new_hash] = stream.id
stream.stream_hash = new_hash
needs_update = True
if needs_update:
streams_to_update.append(stream)
updated += 1
# Bulk update in batches
if len(streams_to_update) >= batch_size:
Stream.objects.bulk_update(
streams_to_update,
['stream_id', 'stream_chno', 'stream_hash'],
batch_size=500
)
logger.info(f"Updated batch: {processed}/{total_count} streams processed")
streams_to_update = []
# Final batch
if streams_to_update:
Stream.objects.bulk_update(
streams_to_update,
['stream_id', 'stream_chno', 'stream_hash'],
batch_size=500
)
# Delete duplicates if any
if duplicates_to_delete:
logger.warning(f"Deleting {len(duplicates_to_delete)} duplicate streams due to hash collisions")
Stream.objects.filter(id__in=duplicates_to_delete).delete()
logger.info(f"Migration complete: {updated} streams updated, {len(duplicates_to_delete)} duplicates removed")
def reverse_migration(apps, schema_editor):
"""
Reverse migration - clear fields but don't attempt to reverse hash changes.
"""
Stream = apps.get_model('dispatcharr_channels', 'Stream')
Stream.objects.all().update(stream_id=None, stream_chno=None)
logger.info("Cleared stream_id and stream_chno fields. Note: stream hashes were not reverted.")
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0032_channel_is_adult_stream_is_adult'),
('m3u', '0018_add_profile_custom_properties'),
('core', '0020_change_coresettings_value_to_jsonfield'),
]
operations = [
# Schema changes - add fields WITHOUT indexes first
migrations.AddField(
model_name='stream',
name='stream_id',
field=models.IntegerField(
blank=True,
help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes',
null=True,
),
),
migrations.AddField(
model_name='stream',
name='stream_chno',
field=models.FloatField(
blank=True,
help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1',
null=True,
),
),
# Data migration (may delete duplicates, which would conflict with pending index creation)
migrations.RunPython(populate_fields_and_rehash, reverse_migration),
# Add indexes AFTER data migration completes
migrations.AddIndex(
model_name='stream',
index=models.Index(fields=['stream_id'], name='dispatcharr_stream_id_idx'),
),
migrations.AddIndex(
model_name='stream',
index=models.Index(fields=['stream_chno'], name='dispatcharr_stream_chno_idx'),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.2.9 on 2026-02-01 03:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0033_stream_id_stream_chno'),
]
operations = [
migrations.RemoveIndex(
model_name='stream',
name='dispatcharr_stream_id_idx',
),
migrations.RemoveIndex(
model_name='stream',
name='dispatcharr_stream_chno_idx',
),
migrations.AlterField(
model_name='stream',
name='stream_chno',
field=models.FloatField(blank=True, db_index=True, help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', null=True),
),
migrations.AlterField(
model_name='stream',
name='stream_id',
field=models.IntegerField(blank=True, db_index=True, help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', null=True),
),
]