357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""
|
||
Schedule generator — respects ChannelSourceRule assignments.
|
||
|
||
Source selection priority:
|
||
1. If any rules with rule_mode='prefer' exist, items from those sources
|
||
are weighted much more heavily.
|
||
2. Items from rule_mode='allow' sources fill the rest.
|
||
3. Items from rule_mode='avoid' sources are only used as a last resort
|
||
(weight × 0.1).
|
||
4. Items from rule_mode='block' sources are NEVER scheduled.
|
||
5. If NO ChannelSourceRule rows exist for this channel, falls back to
|
||
the old behaviour (all items in the channel's library).
|
||
"""
|
||
|
||
import random
|
||
import uuid
|
||
from datetime import datetime, timedelta, date, timezone
|
||
|
||
from core.models import (
|
||
Channel, ChannelSourceRule, ScheduleTemplate,
|
||
ScheduleBlock, Airing, MediaItem,
|
||
)
|
||
|
||
|
||
class ScheduleGenerator:
|
||
"""
|
||
Reads ScheduleTemplate + ScheduleBlocks for a channel and fills the day
|
||
with concrete Airing rows, picking MediaItems according to the channel's
|
||
ChannelSourceRule assignments.
|
||
"""
|
||
|
||
def __init__(self, channel: Channel):
|
||
self.channel = channel
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public API
|
||
# ------------------------------------------------------------------
|
||
|
||
def generate_for_date(self, target_date: date) -> int:
|
||
"""
|
||
Idempotent generation of airings for `target_date`.
|
||
Returns the number of new Airing rows created.
|
||
"""
|
||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
||
template = self._get_template()
|
||
if not template:
|
||
return 0
|
||
|
||
# Resolve the template's local timezone (fall back to UTC)
|
||
try:
|
||
local_tz = ZoneInfo(template.timezone_name or 'UTC')
|
||
except (ZoneInfoNotFoundError, Exception):
|
||
local_tz = ZoneInfo('UTC')
|
||
|
||
target_weekday_bit = 1 << target_date.weekday()
|
||
blocks = template.scheduleblock_set.all().order_by('start_local_time')
|
||
airings_created = 0
|
||
|
||
# Build last_played mapping for the repeat gap
|
||
from core.models import ChannelSourceRule
|
||
rules = ChannelSourceRule.objects.filter(channel=self.channel).select_related('media_source')
|
||
max_gap_hours = 0
|
||
for rule in rules:
|
||
if rule.media_source and rule.media_source.min_repeat_gap_hours:
|
||
max_gap_hours = max(max_gap_hours, rule.media_source.min_repeat_gap_hours)
|
||
|
||
last_played_times = {}
|
||
if max_gap_hours > 0:
|
||
past_dt = datetime.combine(target_date, datetime.min.time(), tzinfo=local_tz).astimezone(timezone.utc) - timedelta(hours=max_gap_hours)
|
||
past_airings = Airing.objects.filter(
|
||
channel=self.channel,
|
||
starts_at__gte=past_dt
|
||
).order_by('starts_at')
|
||
for a in past_airings:
|
||
last_played_times[a.media_item_id] = a.starts_at
|
||
|
||
for block in blocks:
|
||
if not (block.day_of_week_mask & target_weekday_bit):
|
||
continue
|
||
|
||
# Convert local block times to UTC-aware datetimes
|
||
start_local = datetime.combine(target_date, block.start_local_time, tzinfo=local_tz)
|
||
end_local = datetime.combine(target_date, block.end_local_time, tzinfo=local_tz)
|
||
|
||
start_dt = start_local.astimezone(timezone.utc)
|
||
end_dt = end_local.astimezone(timezone.utc)
|
||
|
||
# Midnight-wrap support (e.g. 23:00–02:00 local)
|
||
if end_dt <= start_dt:
|
||
end_dt += timedelta(days=1)
|
||
|
||
# Clear existing airings whose start time is within this block's window
|
||
Airing.objects.filter(
|
||
channel=self.channel,
|
||
starts_at__gte=start_dt,
|
||
starts_at__lt=end_dt,
|
||
).delete()
|
||
|
||
available_items = self._get_weighted_items(block)
|
||
if not available_items:
|
||
continue
|
||
|
||
# Prevent overlaps: ensure we don't start before the end of the previous block's overrun
|
||
latest_prior_airing = Airing.objects.filter(
|
||
channel=self.channel,
|
||
starts_at__lt=start_dt
|
||
).order_by('-ends_at').first()
|
||
|
||
actual_start_dt = start_dt
|
||
if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
|
||
actual_start_dt = latest_prior_airing.ends_at
|
||
|
||
# If the prior block ran all the way through this block's window, skip
|
||
if actual_start_dt >= end_dt:
|
||
continue
|
||
|
||
airings_created += self._fill_block(
|
||
template, block, actual_start_dt, end_dt, available_items, last_played_times
|
||
)
|
||
|
||
return airings_created
|
||
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _get_template(self):
|
||
"""Pick the highest-priority active ScheduleTemplate for this channel."""
|
||
qs = ScheduleTemplate.objects.filter(
|
||
channel=self.channel, is_active=True
|
||
).order_by('-priority')
|
||
return qs.first()
|
||
|
||
def _get_weighted_items(self, block: ScheduleBlock, require_downloaded: bool = False) -> list:
|
||
"""
|
||
Build a weighted pool of MediaItems respecting ChannelSourceRule.
|
||
|
||
If require_downloaded is True, strictly exclude items from YouTube sources
|
||
that have not yet been downloaded (cached_file_path is null).
|
||
|
||
Returns a flat list with items duplicated according to their effective
|
||
weight (rounded to nearest int, min 1) so random.choice() gives the
|
||
right probability distribution without needing numpy.
|
||
"""
|
||
if block.block_type == ScheduleBlock.BlockType.OFF_AIR:
|
||
return []
|
||
|
||
rules = list(
|
||
ChannelSourceRule.objects.filter(channel=self.channel)
|
||
.select_related('media_source')
|
||
)
|
||
|
||
if rules:
|
||
# ── Rules exist: build filtered + weighted pool ───────────────
|
||
allowed_source_ids = set() # allow + prefer
|
||
blocked_source_ids = set() # block
|
||
avoid_source_ids = set() # avoid
|
||
source_weights: dict[int, float] = {}
|
||
|
||
for rule in rules:
|
||
# If a rule has a label, it only applies if this block's name matches
|
||
if rule.schedule_block_label and rule.schedule_block_label != block.name:
|
||
continue
|
||
|
||
sid = rule.media_source_id
|
||
mode = rule.rule_mode
|
||
w = float(rule.weight or 1.0)
|
||
|
||
if mode == 'block':
|
||
blocked_source_ids.add(sid)
|
||
elif mode == 'avoid':
|
||
avoid_source_ids.add(sid)
|
||
source_weights[sid] = w * 0.1 # heavily discounted
|
||
elif mode == 'prefer':
|
||
allowed_source_ids.add(sid)
|
||
source_weights[sid] = w * 3.0 # boosted
|
||
else: # 'allow'
|
||
allowed_source_ids.add(sid)
|
||
source_weights[sid] = w
|
||
|
||
# Build base queryset from allowed + avoid sources (not blocked)
|
||
eligible_source_ids = (allowed_source_ids | avoid_source_ids) - blocked_source_ids
|
||
|
||
if not eligible_source_ids:
|
||
return []
|
||
|
||
base_qs = MediaItem.objects.filter(
|
||
media_source_id__in=eligible_source_ids,
|
||
is_active=True,
|
||
).exclude(item_kind='bumper').select_related('media_source')
|
||
|
||
else:
|
||
# ── No rules: fall back to full library (old behaviour) ────────
|
||
base_qs = MediaItem.objects.filter(
|
||
media_source__library=self.channel.library,
|
||
is_active=True,
|
||
).exclude(item_kind='bumper')
|
||
source_weights = {}
|
||
|
||
# Optionally filter by genre if block specifies one
|
||
if block.default_genre:
|
||
base_qs = base_qs.filter(genres=block.default_genre)
|
||
|
||
# Enforce downloaded requirement for emergency replacements
|
||
if require_downloaded:
|
||
from django.db.models import Q
|
||
from core.services.youtube import YOUTUBE_SOURCE_TYPES
|
||
base_qs = base_qs.exclude(
|
||
Q(media_source__source_type__in=YOUTUBE_SOURCE_TYPES) & Q(cached_file_path__isnull=True)
|
||
)
|
||
|
||
items = list(base_qs)
|
||
if not items:
|
||
return []
|
||
|
||
if not source_weights:
|
||
# No weight information — plain shuffle
|
||
random.shuffle(items)
|
||
return items
|
||
|
||
# Build weighted list: each item appears ⌈weight⌉ times
|
||
weighted: list[MediaItem] = []
|
||
for item in items:
|
||
w = source_weights.get(item.media_source_id, 1.0)
|
||
copies = max(1, round(w))
|
||
weighted.extend([item] * copies)
|
||
|
||
random.shuffle(weighted)
|
||
return weighted
|
||
|
||
def _fill_block(
|
||
self,
|
||
template: ScheduleTemplate,
|
||
block: ScheduleBlock,
|
||
start_dt: datetime,
|
||
end_dt: datetime,
|
||
items: list,
|
||
last_played_times: dict[int, datetime] = None,
|
||
) -> int:
|
||
"""Fill start_dt→end_dt with sequential Airings, cycling through items."""
|
||
cursor = start_dt
|
||
idx = 0
|
||
created = 0
|
||
batch = uuid.uuid4()
|
||
if last_played_times is None:
|
||
last_played_times = {}
|
||
|
||
while cursor < end_dt:
|
||
# Look ahead to find the first item that respects its cooldown rules
|
||
valid_item = None
|
||
items_checked = 0
|
||
while items_checked < len(items):
|
||
candidate = items[idx % len(items)]
|
||
idx += 1
|
||
items_checked += 1
|
||
|
||
# Check cooldown gap
|
||
gap_hours = candidate.media_source.min_repeat_gap_hours if candidate.media_source else None
|
||
if gap_hours:
|
||
last_played = last_played_times.get(candidate.id)
|
||
if last_played:
|
||
if (cursor - last_played).total_seconds() < gap_hours * 3600:
|
||
continue # skip, hasn't been long enough
|
||
|
||
valid_item = candidate
|
||
break
|
||
|
||
if not valid_item:
|
||
# If everything in the pool is currently cooling down, fallback to ignoring cooldowns
|
||
valid_item = items[(idx - 1) % len(items)]
|
||
|
||
item = valid_item
|
||
duration = timedelta(seconds=max(item.runtime_seconds or 1800, 1))
|
||
|
||
# Don't let a single item overshoot the end by more than its own length
|
||
if cursor + duration > end_dt + timedelta(hours=1):
|
||
break
|
||
|
||
Airing.objects.create(
|
||
channel=self.channel,
|
||
schedule_template=template,
|
||
schedule_block=block,
|
||
media_item=item,
|
||
starts_at=cursor,
|
||
ends_at=cursor + duration,
|
||
slot_kind="program",
|
||
status="scheduled",
|
||
source_reason="template",
|
||
generation_batch_uuid=batch,
|
||
)
|
||
|
||
last_played_times[item.id] = cursor
|
||
|
||
cursor += duration
|
||
created += 1
|
||
|
||
return created
|
||
|
||
def replace_undownloaded_airings(self, airings: list[Airing]):
|
||
"""
|
||
Takes a list of specific Airings that failed to download or are
|
||
too close to airtime without a valid cache file. Replaces the
|
||
underlying media_item with one guaranteed to be playable, and
|
||
ripple-shifts all following airings on the channel by the duration diff.
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
for original_airing in airings:
|
||
# 1. First check if the channel has a dedicated error fallback collection
|
||
safe_items = []
|
||
if getattr(self.channel, 'fallback_collection', None):
|
||
safe_items = list(self.channel.fallback_collection.media_items.exclude(
|
||
cached_file_path__isnull=True,
|
||
media_source__source_type__in=['youtube', 'youtube_channel', 'youtube_playlist']
|
||
))
|
||
|
||
# 2. If no fallback collection or it yielded no valid items, try block sources
|
||
if not safe_items:
|
||
safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True)
|
||
|
||
if not safe_items:
|
||
logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for fallback or block {original_airing.schedule_block.name}")
|
||
continue
|
||
# 2. Pick a random valid fallback item
|
||
fallback_item = random.choice(safe_items)
|
||
old_duration = original_airing.ends_at - original_airing.starts_at
|
||
|
||
# Update the original airing to reference the new item
|
||
original_airing.media_item = fallback_item
|
||
original_airing.source_reason = 'recovery'
|
||
|
||
new_duration = timedelta(seconds=max(fallback_item.runtime_seconds or 1800, 1))
|
||
original_airing.ends_at = original_airing.starts_at + new_duration
|
||
original_airing.save(update_fields=['media_item', 'source_reason', 'ends_at'])
|
||
|
||
logger.info(f"Replaced airing {original_airing.id} with '{fallback_item.title}' (diff: {new_duration - old_duration})")
|
||
|
||
# 3. Ripple shift downstream airings accurately
|
||
delta = new_duration - old_duration
|
||
|
||
if delta.total_seconds() != 0:
|
||
# Find all airings strictly after this one on the same channel
|
||
downstream = Airing.objects.filter(
|
||
channel=self.channel,
|
||
starts_at__gte=original_airing.starts_at + old_duration
|
||
).exclude(id=original_airing.id).order_by('starts_at')
|
||
|
||
# Apply shift
|
||
for later_airing in downstream:
|
||
later_airing.starts_at += delta
|
||
later_airing.ends_at += delta
|
||
later_airing.save(update_fields=['starts_at', 'ends_at'])
|