Dispatcharr/apps/channels/signals.py

367 lines
15 KiB
Python

# apps/channels/signals.py
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete, pre_delete
from django.dispatch import receiver
from django.utils.timezone import now, is_aware, make_aware
from celery.result import AsyncResult
from django_celery_beat.models import ClockedSchedule, PeriodicTask
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording
from apps.m3u.models import M3UAccount
from apps.epg.tasks import parse_programs_for_tvg_id
import json
import logging
from .tasks import run_recording, prefetch_recording_artwork
from datetime import timedelta
logger = logging.getLogger(__name__)
@receiver(m2m_changed, sender=Channel.streams.through)
def update_channel_tvg_id_and_logo(sender, instance, action, reverse, model, pk_set, **kwargs):
"""
Whenever streams are added to a channel:
1) If the channel doesn't have a tvg_id, fill it from the first newly-added stream that has one.
"""
# We only care about post_add, i.e. once the new streams are fully associated
if action == "post_add":
# --- 1) Populate channel.tvg_id if empty ---
if not instance.tvg_id:
# Look for newly added streams that have a nonempty tvg_id
streams_with_tvg = model.objects.filter(pk__in=pk_set).exclude(tvg_id__exact='')
if streams_with_tvg.exists():
instance.tvg_id = streams_with_tvg.first().tvg_id
instance.save(update_fields=['tvg_id'])
@receiver(pre_save, sender=Stream)
def set_default_m3u_account(sender, instance, **kwargs):
"""
This function will be triggered before saving a Stream instance.
It sets the default m3u_account if not provided.
"""
if not instance.m3u_account:
instance.is_custom = True
default_account = M3UAccount.get_custom_account()
if default_account:
instance.m3u_account = default_account
else:
raise ValueError("No default M3UAccount found.")
@receiver(post_save, sender=Stream)
def generate_custom_stream_hash(sender, instance, created, **kwargs):
"""
Generate a stable stream_hash for custom streams after creation.
Uses the stream's ID to ensure the hash never changes even if name/url is edited.
"""
if instance.is_custom and not instance.stream_hash and created:
import hashlib
# Use stream ID for a stable, unique hash that never changes
unique_string = f"custom_stream_{instance.id}"
instance.stream_hash = hashlib.sha256(unique_string.encode()).hexdigest()
# Use update to avoid triggering signals again
Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash)
@receiver(pre_delete, sender=Channel)
def stop_proxy_session_before_channel_delete(sender, instance, **kwargs):
"""
When a Channel is deleted, stop any active TS proxy session for it first.
Without this, the proxy's Redis state (live:channel:{uuid}:*) survives
the Channel row and the connected clients' "Stop" button hits
`ChannelService.stop_channel(uuid)`, which calls `Channel.objects.get(uuid=...)`
and crashes with DoesNotExist (reported as 'Channel not found' in the UI).
Users then cannot close the stream; source-side connection limits stay
consumed. Covers manual deletes, bulk deletes, and sync-driven deletes
via the same signal path.
"""
try:
from apps.proxy.live_proxy.services.channel_service import ChannelService
channel_uuid = str(instance.uuid) if instance.uuid else None
if not channel_uuid:
return
# Best-effort: if the channel has no active session the service
# returns a benign 'Channel not found' result, which is ignored.
ChannelService.stop_channel(channel_uuid)
except Exception as e:
# Never block a channel delete on proxy cleanup failure. Log and
# continue so at least the DB row is removed.
logger.warning(
"Failed to stop proxy session before deleting channel %s: %s",
getattr(instance, "id", "<unknown>"),
e,
)
@receiver(post_save, sender=Channel)
def assign_compact_number_on_unhide(sender, instance, created, **kwargs):
"""When a channel transitions from hidden to visible under compact
numbering, immediately assign the next available number from its
group's range. Without this, an unhide would leave the channel at
NULL until the next M3U refresh, which is too long a delay for what
is meant to feel like an instant action.
Bails out for the common cases where assignment is not appropriate:
a fresh channel (newly created), a channel that already has a
number, a hidden channel, a manual channel, or a channel whose group
is not in compact mode. The assignment helper does the same checks
defensively, but bailing here keeps the signal out of the helper
code path on every channel save.
"""
if created:
return
# Skip the signal when update_fields proves hidden_from_output was not
# touched. Sync sets update_fields on every save, so this keeps the
# signal off the sync hot path.
update_fields = kwargs.get("update_fields")
if update_fields is not None and "hidden_from_output" not in update_fields:
return
if instance.hidden_from_output:
return
if instance.channel_number is not None:
return
if not instance.auto_created or not instance.auto_created_by_id:
return
try:
from .compact_numbering import assign_compact_numbers_for_channels
assign_compact_numbers_for_channels([instance.id])
# The helper writes via queryset .update() which skips
# in-memory state. Reload so a same-request serializer
# response carries the assigned number, not stale None.
try:
instance.refresh_from_db(fields=["channel_number"])
except Exception:
# Refresh failure (race with delete) is non-fatal.
pass
except Exception as e:
# Do not propagate. The save succeeded; the assignment is
# recoverable on next sync or manual repack.
logger.warning(
"Compact unhide assignment failed for channel %s: %s",
instance.id,
e,
)
@receiver(post_save, sender=Channel)
def release_compact_number_on_hide(sender, instance, created, **kwargs):
"""When a channel transitions from visible to hidden under compact
numbering, immediately release its channel_number slot. Without
this, the slot stays occupied until the next sync's repack pass,
so the user sees their hidden channels still consuming numbers in
the table for an indeterminate window. Mirror image of
`assign_compact_number_on_unhide` above.
Bails out for the common cases where release is not appropriate:
a fresh channel (newly created), a non-hidden channel, a channel
that has no number to release, a manual channel, a channel without
a known auto_created_by, or a channel whose group is not in
compact mode.
"""
if created:
return
update_fields = kwargs.get("update_fields")
if update_fields is not None and "hidden_from_output" not in update_fields:
return
if not instance.hidden_from_output:
return
if instance.channel_number is None:
return
if not instance.auto_created or not instance.auto_created_by_id:
return
try:
from .compact_numbering import (
get_group_relation_for_channel,
is_compact_group,
)
relation = get_group_relation_for_channel(instance)
if not relation or not is_compact_group(relation):
return
except Exception:
return
# Release the slot. Queryset .update() bypasses the post_save signal
# chain that this handler is itself running inside.
Channel.objects.filter(id=instance.id).update(channel_number=None)
# Refresh in-memory instance so the same-request serializer
# response surfaces channel_number=None instead of stale value.
try:
instance.refresh_from_db(fields=["channel_number"])
except Exception:
pass
@receiver(post_save, sender=Channel)
def refresh_epg_programs(sender, instance, created, **kwargs):
"""
When a channel is saved, check if the EPG data has changed.
If so, trigger a refresh of the program data for the EPG.
"""
# Check if this is an update (not a new channel) and the epg_data has changed
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
if instance.epg_data:
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
parse_programs_for_tvg_id.delay(instance.epg_data.id)
# For new channels with EPG data, also refresh
elif created and instance.epg_data:
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
parse_programs_for_tvg_id.delay(instance.epg_data.id)
@receiver(post_save, sender=ChannelProfile)
def create_profile_memberships(sender, instance, created, **kwargs):
if created:
channels = Channel.objects.all()
ChannelProfileMembership.objects.bulk_create([
ChannelProfileMembership(channel_profile=instance, channel=channel)
for channel in channels
])
def _dvr_task_name(recording_id):
"""Predictable PeriodicTask name for a DVR recording."""
return f"dvr-recording-{recording_id}"
def schedule_recording_task(instance, eta=None):
"""Schedule a recording task via ClockedSchedule + one-off PeriodicTask.
The task is stored in the database and dispatched by Celery Beat at the
scheduled time with no countdown. This avoids the Redis visibility_timeout
redelivery bug that caused duplicate recordings when using apply_async
with long countdowns.
"""
if eta is None:
eta = instance.start_time
if eta is not None and not is_aware(eta):
eta = make_aware(eta)
# Clamp to now so Beat dispatches immediately for past/current start times
if eta <= now():
eta = now()
task_args = [
instance.id,
instance.channel_id,
str(instance.start_time),
str(instance.end_time),
]
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta)
task_name = _dvr_task_name(instance.id)
PeriodicTask.objects.update_or_create(
name=task_name,
defaults={
"task": "apps.channels.tasks.run_recording",
"clocked": clocked,
"args": json.dumps(task_args),
"one_off": True,
"enabled": True,
"interval": None,
"crontab": None,
"solar": None,
},
)
return task_name
def revoke_task(task_id):
"""Cancel a pending recording task.
task_id is normally a PeriodicTask name (e.g. "dvr-recording-42").
For backwards compatibility with legacy Celery async-result UUIDs,
falls back to AsyncResult.revoke().
"""
if not task_id:
return
# Primary path: delete the PeriodicTask and clean up its ClockedSchedule
try:
pt = PeriodicTask.objects.get(name=task_id)
old_clocked = pt.clocked
pt.delete()
if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists():
old_clocked.delete()
return
except PeriodicTask.DoesNotExist:
pass
# Fallback for legacy Celery task UUIDs
try:
AsyncResult(task_id).revoke()
except Exception:
pass
@receiver(pre_save, sender=Recording)
def revoke_old_task_on_update(sender, instance, **kwargs):
if not instance.pk:
return # New instance
try:
old = Recording.objects.get(pk=instance.pk)
if old.task_id and (
old.start_time != instance.start_time or
old.end_time != instance.end_time or
old.channel_id != instance.channel_id
):
# Do NOT revoke while the recording is actively streaming.
# run_recording re-reads end_time from the DB every ~2 s and extends
# its internal deadline dynamically — revoking here would kill the task.
old_status = (old.custom_properties or {}).get("status", "")
if old_status == "recording":
return
revoke_task(old.task_id)
instance.task_id = None
except Recording.DoesNotExist:
pass
@receiver(post_save, sender=Recording)
def schedule_task_on_save(sender, instance, created, **kwargs):
try:
# Skip processing for internal field-only saves (metadata updates,
# task_id assignment, end_time extensions) to prevent re-entrant
# artwork dispatch and redundant recording_updated WS events.
update_fields = kwargs.get('update_fields')
if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}:
return
if not instance.task_id:
start_time = instance.start_time
end_time = instance.end_time
# Make datetimes aware (in UTC)
if not is_aware(start_time):
start_time = make_aware(start_time)
if end_time and not is_aware(end_time):
end_time = make_aware(end_time)
current_time = now()
if start_time > current_time - timedelta(seconds=1):
# Future recording — schedule at start_time
logger.info(f"Recording {instance.id}: scheduling task at {start_time}")
task_id = schedule_recording_task(instance, eta=start_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
elif end_time and end_time > current_time:
# Currently-playing — start immediately (e.g. series rule for in-progress program)
logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately")
task_id = schedule_recording_task(instance, eta=current_time)
instance.task_id = task_id
instance.save(update_fields=['task_id'])
else:
logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling")
# Kick off poster/artwork prefetch to enrich Upcoming cards.
# Skip when the recording is already active or finished — run_recording
# handles its own poster resolution, and scheduling artwork prefetch
# while the task is running causes a race that can overwrite status.
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
if rec_status not in ("recording", "completed", "stopped", "interrupted"):
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
except Exception as e:
import traceback
print("Error in post_save signal:", e)
traceback.print_exc()
@receiver(post_delete, sender=Recording)
def revoke_task_on_delete(sender, instance, **kwargs):
revoke_task(instance.task_id)