from ninja import Router, Schema from typing import List, Optional from core.models import Channel, AppUser, Library, ChannelSourceRule, MediaSource, Airing from django.shortcuts import get_object_or_404 from django.utils import timezone from datetime import datetime, timedelta router = Router(tags=["channel"]) class ChannelSchema(Schema): id: int name: str slug: str channel_number: Optional[int] = None description: Optional[str] = None scheduling_mode: str library_id: int owner_user_id: int class ChannelCreateSchema(Schema): name: str slug: str channel_number: Optional[int] = None description: Optional[str] = None library_id: int owner_user_id: int # Mock Auth User class ChannelUpdateSchema(Schema): name: Optional[str] = None description: Optional[str] = None channel_number: Optional[int] = None scheduling_mode: Optional[str] = None visibility: Optional[str] = None is_active: Optional[bool] = None class ChannelSourceRuleSchema(Schema): id: int source_id: int source_name: str rule_mode: str weight: float class ChannelSourceAssignSchema(Schema): source_id: int rule_mode: str = 'allow' # allow | prefer | avoid | block weight: float = 1.0 class AiringSchema(Schema): id: int media_item_title: str media_item_path: Optional[str] = None starts_at: datetime ends_at: datetime slot_kind: str status: str @staticmethod def from_airing(airing) -> 'AiringSchema': media_path = None if airing.media_item: raw_path = airing.media_item.cached_file_path or airing.media_item.file_path if raw_path: if raw_path.startswith("http://") or raw_path.startswith("https://"): media_path = raw_path else: from django.conf import settings import os try: rel_path = os.path.relpath(raw_path, settings.MEDIA_ROOT) if not rel_path.startswith("..") and not os.path.isabs(rel_path): base = settings.MEDIA_URL.rstrip('/') media_path = f"{base}/{rel_path}" else: media_path = raw_path except ValueError: media_path = raw_path return AiringSchema( id=airing.id, media_item_title=airing.media_item.title if airing.media_item else 'Unknown', media_item_path=media_path, starts_at=airing.starts_at, ends_at=airing.ends_at, slot_kind=airing.slot_kind, status=airing.status, ) @router.get("/", response=List[ChannelSchema]) def list_channels(request): return Channel.objects.all() @router.get("/{channel_id}", response=ChannelSchema) def get_channel(request, channel_id: int): return get_object_or_404(Channel, id=channel_id) @router.post("/", response={201: ChannelSchema}) def create_channel(request, payload: ChannelCreateSchema): owner = get_object_or_404(AppUser, id=payload.owner_user_id) library = get_object_or_404(Library, id=payload.library_id) channel = Channel.objects.create( owner_user=owner, library=library, name=payload.name, slug=payload.slug, channel_number=payload.channel_number, description=payload.description ) return 201, channel @router.patch("/{channel_id}", response=ChannelSchema) def update_channel(request, channel_id: int, payload: ChannelUpdateSchema): channel = get_object_or_404(Channel, id=channel_id) for attr, value in payload.dict(exclude_unset=True).items(): setattr(channel, attr, value) channel.save() return channel @router.delete("/{channel_id}", response={204: None}) def delete_channel(request, channel_id: int): channel = get_object_or_404(Channel, id=channel_id) channel.delete() return 204, None @router.get("/{channel_id}/sources", response=List[ChannelSourceRuleSchema]) def list_channel_sources(request, channel_id: int): channel = get_object_or_404(Channel, id=channel_id) rules = ChannelSourceRule.objects.filter(channel=channel, media_source__isnull=False).select_related('media_source') return [ ChannelSourceRuleSchema( id=r.id, source_id=r.media_source.id, source_name=r.media_source.name, rule_mode=r.rule_mode, weight=float(r.weight), ) for r in rules ] @router.post("/{channel_id}/sources", response={201: ChannelSourceRuleSchema}) def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAssignSchema): channel = get_object_or_404(Channel, id=channel_id) source = get_object_or_404(MediaSource, id=payload.source_id) rule = ChannelSourceRule.objects.create( channel=channel, media_source=source, rule_mode=payload.rule_mode, weight=payload.weight, ) return 201, ChannelSourceRuleSchema( id=rule.id, source_id=source.id, source_name=source.name, rule_mode=rule.rule_mode, weight=float(rule.weight), ) @router.delete("/{channel_id}/sources/{rule_id}", response={204: None}) def remove_source_from_channel(request, channel_id: int, rule_id: int): rule = get_object_or_404(ChannelSourceRule, id=rule_id, channel_id=channel_id) rule.delete() return 204, None @router.get("/{channel_id}/now", response=Optional[AiringSchema]) def channel_now_playing(request, channel_id: int): """Return the Airing currently on-air for this channel, or null.""" channel = get_object_or_404(Channel, id=channel_id) # Using a 1-second buffer to handle boundary conditions smoothly now = timezone.now() airing = ( Airing.objects .filter(channel=channel, starts_at__lte=now, ends_at__gt=now) .select_related('media_item') .first() ) if airing is None: return None return AiringSchema.from_airing(airing) @router.get("/{channel_id}/airings", response=List[AiringSchema]) def channel_airings(request, channel_id: int, hours: int = 4): """ Return Airings for this channel that overlap with the window: [now - 2 hours, now + {hours} hours] """ channel = get_object_or_404(Channel, id=channel_id) now = timezone.now() window_start = now - timedelta(hours=2) # Look back 2h for context window_end = now + timedelta(hours=hours) # Logic for overlap: starts_at < window_end AND ends_at > window_start airings = ( Airing.objects .filter( channel=channel, starts_at__lt=window_end, ends_at__gt=window_start ) .select_related('media_item') .order_by('starts_at') ) return [AiringSchema.from_airing(a) for a in airings]