feat(main): commit
This commit is contained in:
@@ -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:00–02: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
|
||||
|
||||
Reference in New Issue
Block a user