feat(main): commit

This commit is contained in:
2026-03-08 16:48:58 -04:00
parent 567766eaed
commit f37382d2b8
29 changed files with 3735 additions and 223 deletions

View File

@@ -1,108 +1,210 @@
from datetime import datetime, timedelta, date, time, timezone
from core.models import Channel, ScheduleTemplate, ScheduleBlock, Airing, MediaItem
"""
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:
"""
A service that reads the latest ScheduleTemplate and Blocks for a given channel
and generates concrete Airings logic based on available matching MediaItems.
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 a specific date on this channel.
Returns the number of new Airings created.
Idempotent generation of airings for `target_date`.
Returns the number of new Airing rows created.
"""
# 1. Get the highest priority active template valid on this date
template = ScheduleTemplate.objects.filter(
channel=self.channel,
is_active=True
).filter(
# Start date is null or <= target_date
valid_from_date__isnull=True
).order_by('-priority').first()
# In a real app we'd construct complex Q objects for the valid dates,
# but for PYTV mock we will just grab the highest priority active template.
template = self._get_template()
if not template:
template = ScheduleTemplate.objects.filter(channel=self.channel, is_active=True).order_by('-priority').first()
if not template:
return 0
# 2. Extract day of week mask
# Python weekday: 0=Monday, 6=Sunday
# Our mask: bit 0 = Monday, bit 6 = Sunday
return 0
target_weekday_bit = 1 << target_date.weekday()
blocks = template.scheduleblock_set.all()
airings_created = 0
for block in blocks:
# Check if block runs on this day
if not (block.day_of_week_mask & target_weekday_bit):
continue
# Naive time combining mapping local time to UTC timeline without specific tz logic for simplicity now
start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc)
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
# If the block wraps past midnight (e.g. 23:00 to 02:00)
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
# Midnight-wrap support (e.g. 23:0002:00)
if end_dt <= start_dt:
end_dt += timedelta(days=1)
# Clear existing airings in this window to allow idempotency
# Clear existing airings in this window (idempotency)
Airing.objects.filter(
channel=self.channel,
starts_at__gte=start_dt,
starts_at__lt=end_dt
starts_at__lt=end_dt,
).delete()
# 3. Pull matching Media Items
# Simplistic matching: pull items from library matching the block's genre
items_query = MediaItem.objects.filter(media_source__library=self.channel.library)
if block.default_genre:
items_query = items_query.filter(genres=block.default_genre)
available_items = list(items_query.exclude(item_kind="bumper"))
available_items = self._get_weighted_items(block)
if not available_items:
continue
# Shuffle randomly for basic scheduling variety
random.shuffle(available_items)
# 4. Fill the block
current_cursor = start_dt
item_index = 0
while current_cursor < end_dt and item_index < len(available_items):
item = available_items[item_index]
duration = timedelta(seconds=item.runtime_seconds or 3600)
# Check if this item fits
if current_cursor + duration > end_dt:
# Item doesn't strictly fit, but we'll squeeze it in and break if needed
# Real systems pad this out or trim the slot.
pass
import uuid
Airing.objects.create(
channel=self.channel,
schedule_template=template,
schedule_block=block,
media_item=item,
starts_at=current_cursor,
ends_at=current_cursor + duration,
slot_kind="program",
status="scheduled",
source_reason="template",
generation_batch_uuid=uuid.uuid4()
)
current_cursor += duration
item_index += 1
airings_created += 1
airings_created += self._fill_block(
template, block, start_dt, end_dt, available_items
)
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) -> list:
"""
Build a weighted pool of MediaItems respecting ChannelSourceRule.
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.
"""
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:
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)
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,
) -> int:
"""Fill start_dt→end_dt with sequential Airings, cycling through items."""
cursor = start_dt
idx = 0
created = 0
batch = uuid.uuid4()
while cursor < end_dt:
item = items[idx % len(items)]
idx += 1
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,
)
cursor += duration
created += 1
return created