feat(main): initial commit

This commit is contained in:
2026-03-08 11:28:59 -04:00
commit 458ceb31b1
66 changed files with 7885 additions and 0 deletions

0
core/__init__.py Normal file
View File

3
core/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
core/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "core"

View File

View File

View File

@@ -0,0 +1,209 @@
import random
from datetime import time, timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from core.models import (
AppUser, Library, Genre, ContentRating, MediaSource,
Series, MediaItem, Channel, ScheduleTemplate, ScheduleBlock, Airing
)
class Command(BaseCommand):
help = 'Seeds the database with mock PYTV data including users, libraries, media, and channels.'
def handle(self, *args, **kwargs):
self.stdout.write("Flushing existing data...")
# Since we cascade everything, deleting users usually drops almost everything
AppUser.objects.filter(username__startswith='mock_').delete()
Genre.objects.all().delete()
ContentRating.objects.all().delete()
self.stdout.write("Seeding Users...")
admin_user = AppUser.objects.create_superuser('mock_admin', 'admin@pytv.local', 'admin')
viewer_user = AppUser.objects.create_user('mock_viewer', 'viewer@pytv.local', 'password')
self.stdout.write("Seeding Genres & Ratings...")
g_action = Genre.objects.create(name="Action")
g_comedy = Genre.objects.create(name="Comedy")
g_drama = Genre.objects.create(name="Drama")
g_scifi = Genre.objects.create(name="Sci-Fi")
g_promo = Genre.objects.create(name="Promo/Bumper")
r_pg = ContentRating.objects.create(system_name="TV Parental Guidelines", code="TV-PG", min_age=8)
r_14 = ContentRating.objects.create(system_name="TV Parental Guidelines", code="TV-14", min_age=14)
r_ma = ContentRating.objects.create(system_name="TV Parental Guidelines", code="TV-MA", min_age=17)
self.stdout.write("Seeding Library & Media Sources...")
lib = Library.objects.create(
owner_user=admin_user,
name="Main Broadcasting Library",
visibility="public",
description="The core streaming library for PYTV mock."
)
source_movies = MediaSource.objects.create(
library=lib,
name="Mocked Local Movies",
source_type="local_directory",
uri="/mock/movies"
)
source_tv = MediaSource.objects.create(
library=lib,
name="Mocked Local TV Shows",
source_type="local_directory",
uri="/mock/tv"
)
source_bumpers = MediaSource.objects.create(
library=lib,
name="Network Bumpers",
source_type="local_directory",
uri="/mock/bumpers"
)
self.stdout.write("Seeding Series & Media Items...")
# Movies
m1 = MediaItem.objects.create(
media_source=source_movies,
title="Space Rangers 3000",
item_kind="movie",
release_year=1999,
runtime_seconds=5400, # 1.5 hours
file_path="/mock/movies/space_rangers.mp4",
content_rating=r_pg
)
m1.genres.add(g_action, g_scifi)
m2 = MediaItem.objects.create(
media_source=source_movies,
title="The Laughing Policeman",
item_kind="movie",
release_year=2005,
runtime_seconds=6300, # 1.75 hours
file_path="/mock/movies/laughing_policeman.mp4",
content_rating=r_14
)
m2.genres.add(g_comedy, g_action)
# Series
s1 = Series.objects.create(title="Neon City Nights", description="Cyberpunk detective drama.", release_year=2024)
for ep in range(1, 6):
ep_item = MediaItem.objects.create(
media_source=source_tv,
series=s1,
title=f"Episode {ep}",
item_kind="episode",
season_number=1,
episode_number=ep,
runtime_seconds=2700, # 45 mins
file_path=f"/mock/tv/neon_city_nights/s01e0{ep}.mkv",
content_rating=r_ma
)
ep_item.genres.add(g_drama, g_scifi)
# Bumpers
for b in range(1, 4):
bump = MediaItem.objects.create(
media_source=source_bumpers,
title=f"Station Bumper {b}",
item_kind="bumper",
runtime_seconds=15,
file_path=f"/mock/bumpers/ident_{b}.mp4"
)
bump.genres.add(g_promo)
self.stdout.write("Seeding Channels and Scheduling Blocks...")
ch1 = Channel.objects.create(
owner_user=admin_user,
library=lib,
name="PYTV One",
slug="pytv-1",
channel_number=1,
description="The flagship generic network.",
scheduling_mode="template_driven",
visibility="public"
)
template = ScheduleTemplate.objects.create(
channel=ch1,
name="Standard Weekday",
timezone_name="UTC",
priority=10
)
# Prime time block
b_prime = ScheduleBlock.objects.create(
schedule_template=template,
name="Prime Time Drama",
block_type="programming",
start_local_time=time(20, 0),
end_local_time=time(23, 0),
day_of_week_mask=127, # Everyday
default_genre=g_drama,
rotation_strategy="sequential",
pad_strategy="fill_with_interstitials"
)
ch2 = Channel.objects.create(
owner_user=admin_user,
library=lib,
name="Tears of Steel Channel",
slug="tears-of-steel",
channel_number=2,
description="All Sci-Fi all the time.",
scheduling_mode="template_driven",
visibility="public"
)
template2 = ScheduleTemplate.objects.create(
channel=ch2,
name="Sci-Fi Everyday",
timezone_name="UTC",
priority=10
)
ScheduleBlock.objects.create(
schedule_template=template2,
name="Sci-Fi Block",
block_type="programming",
start_local_time=time(0, 0),
end_local_time=time(23, 59, 59),
day_of_week_mask=127,
default_genre=g_scifi,
rotation_strategy="random",
pad_strategy="none"
)
ch3 = Channel.objects.create(
owner_user=admin_user,
library=lib,
name="Sintel Classics",
slug="sintel-classics",
channel_number=3,
description="Classic movies and animation.",
scheduling_mode="template_driven",
visibility="public"
)
template3 = ScheduleTemplate.objects.create(
channel=ch3,
name="Comedy and Action",
timezone_name="UTC",
priority=10
)
ScheduleBlock.objects.create(
schedule_template=template3,
name="Action Comedy",
block_type="programming",
start_local_time=time(0, 0),
end_local_time=time(23, 59, 59),
day_of_week_mask=127,
default_genre=g_action,
rotation_strategy="random",
pad_strategy="none"
)
self.stdout.write(self.style.SUCCESS("Successfully seeded the PYTV database with mock data."))

View File

@@ -0,0 +1,60 @@
from django.core.management.base import BaseCommand
from core.models import AppUser, Library, Channel, MediaItem, Airing, ScheduleTemplate
from django.utils import timezone
from datetime import timedelta
class Command(BaseCommand):
help = "Displays a beautifully formatted terminal dashboard of the current backend state."
def get_color(self, text, code):
"""Helper to wrap string in bash color codes"""
return f"\033[{code}m{text}\033[0m"
def handle(self, *args, **options):
# 1. Gather Aggregate Metrics
total_users = AppUser.objects.count()
total_libraries = Library.objects.count()
total_channels = Channel.objects.count()
total_media = MediaItem.objects.count()
total_templates = ScheduleTemplate.objects.count()
# Gather near-term Airing metrics
now = timezone.now()
tomorrow = now + timedelta(days=1)
airings_today = Airing.objects.filter(starts_at__gte=now, starts_at__lt=tomorrow).count()
airings_total = Airing.objects.count()
self.stdout.write(self.get_color("\n=== PYTV Backend State Dashboard ===", "1;34"))
self.stdout.write(f"\n{self.get_color('Core Metrics:', '1;36')}")
self.stdout.write(f" Users: {self.get_color(str(total_users), '1;32')}")
self.stdout.write(f" Libraries: {self.get_color(str(total_libraries), '1;32')}")
self.stdout.write(f" Media Items: {self.get_color(str(total_media), '1;32')}")
self.stdout.write(f" Channels: {self.get_color(str(total_channels), '1;32')}")
self.stdout.write(f"\n{self.get_color('Scheduling Engine:', '1;36')}")
self.stdout.write(f" Active Templates: {self.get_color(str(total_templates), '1;33')}")
self.stdout.write(f" Airings (Next 24h): {self.get_color(str(airings_today), '1;33')}")
self.stdout.write(f" Airings (Total): {self.get_color(str(airings_total), '1;33')}")
# 2. Build Tree View of Channels
self.stdout.write(f"\n{self.get_color('Channel Tree:', '1;36')}")
channels = Channel.objects.prefetch_related('scheduletemplate_set').all()
if not channels:
self.stdout.write(" (No channels configured)")
for c in channels:
status_color = "1;32" if c.is_active else "1;31"
status_text = "ACTIVE" if c.is_active else "INACTIVE"
self.stdout.write(f"\n 📺 [{c.channel_number or '-'}] {c.name} ({self.get_color(status_text, status_color)})")
# Show templates
templates = c.scheduletemplate_set.filter(is_active=True).order_by('-priority')
if not templates:
self.stdout.write(" ⚠️ No active schedule templates.")
else:
for t in templates:
blocks_count = t.scheduleblock_set.count()
self.stdout.write(f" 📄 Template: {t.name} (Priority {t.priority}) -> {blocks_count} Blocks")
self.stdout.write("\n")

File diff suppressed because it is too large Load Diff

View File

490
core/models.py Normal file
View File

@@ -0,0 +1,490 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
# ------------------------------------------------------------
# users and access control
# ------------------------------------------------------------
class AppUser(AbstractUser):
# Using Django's built-in AbstractUser which covers username, email, password_hash (password),
# last_login, created_at (date_joined), is_admin (is_staff/is_superuser).
pass
class Library(models.Model):
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
class Visibility(models.TextChoices):
PRIVATE = 'private', 'Private'
SHARED = 'shared', 'Shared'
PUBLIC = 'public', 'Public'
visibility = models.CharField(
max_length=16,
choices=Visibility.choices,
default=Visibility.PRIVATE
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner_user', 'name'], name='unique_library_name_per_user')
]
class LibraryMember(models.Model):
library = models.ForeignKey(Library, on_delete=models.CASCADE)
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
class Role(models.TextChoices):
VIEWER = 'viewer', 'Viewer'
EDITOR = 'editor', 'Editor'
MANAGER = 'manager', 'Manager'
role = models.CharField(max_length=16, choices=Role.choices)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['library', 'user'], name='unique_library_member')
]
# ------------------------------------------------------------
# media metadata
# ------------------------------------------------------------
class Genre(models.Model):
parent_genre = models.ForeignKey('self', on_delete=models.SET_NULL, blank=True, null=True)
name = models.CharField(max_length=128, unique=True)
description = models.TextField(blank=True, null=True)
class ContentRating(models.Model):
system_name = models.CharField(max_length=64)
code = models.CharField(max_length=32)
description = models.TextField(blank=True, null=True)
min_age = models.IntegerField(blank=True, null=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['system_name', 'code'], name='unique_content_rating')
]
class MediaSource(models.Model):
library = models.ForeignKey(Library, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
class SourceType(models.TextChoices):
LOCAL_DIRECTORY = 'local_directory', 'Local Directory'
NETWORK_SHARE = 'network_share', 'Network Share'
MANUAL_IMPORT = 'manual_import', 'Manual Import'
PLAYLIST = 'playlist', 'Playlist'
STREAM = 'stream', 'Stream'
API_FEED = 'api_feed', 'API Feed'
source_type = models.CharField(max_length=32, choices=SourceType.choices)
uri = models.TextField()
recursive_scan = models.BooleanField(default=True)
include_commercials = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
scan_interval_minutes = models.IntegerField(blank=True, null=True)
last_scanned_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['library', 'name'], name='unique_media_source_name_per_library')
]
class MediaSourceRule(models.Model):
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE)
class RuleType(models.TextChoices):
INCLUDE_GLOB = 'include_glob', 'Include Glob'
EXCLUDE_GLOB = 'exclude_glob', 'Exclude Glob'
INCLUDE_REGEX = 'include_regex', 'Include Regex'
EXCLUDE_REGEX = 'exclude_regex', 'Exclude Regex'
rule_type = models.CharField(max_length=24, choices=RuleType.choices)
rule_value = models.TextField()
sort_order = models.IntegerField(default=0)
class Series(models.Model):
title = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
release_year = models.IntegerField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class MediaItem(models.Model):
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE)
series = models.ForeignKey(Series, on_delete=models.SET_NULL, blank=True, null=True)
title = models.CharField(max_length=255)
sort_title = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
class ItemKind(models.TextChoices):
MOVIE = 'movie', 'Movie'
EPISODE = 'episode', 'Episode'
SPECIAL = 'special', 'Special'
MUSIC_VIDEO = 'music_video', 'Music Video'
BUMPER = 'bumper', 'Bumper'
INTERSTITIAL = 'interstitial', 'Interstitial'
COMMERCIAL = 'commercial', 'Commercial'
item_kind = models.CharField(max_length=24, choices=ItemKind.choices, db_index=True)
season_number = models.IntegerField(blank=True, null=True)
episode_number = models.IntegerField(blank=True, null=True)
release_year = models.IntegerField(blank=True, null=True)
runtime_seconds = models.PositiveIntegerField()
file_path = models.TextField()
file_hash = models.CharField(max_length=128, blank=True, null=True)
thumbnail_path = models.TextField(blank=True, null=True)
language_code = models.CharField(max_length=16, blank=True, null=True)
content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True)
is_active = models.BooleanField(default=True)
date_added_at = models.DateTimeField(auto_now_add=True)
metadata_json = models.JSONField(default=dict)
genres = models.ManyToManyField(Genre, related_name="media_items", blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['media_source', 'file_path'], name='unique_media_item_path_per_source')
]
class MediaCollection(models.Model):
library = models.ForeignKey(Library, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
class CollectionType(models.TextChoices):
MANUAL = 'manual', 'Manual'
SMART = 'smart', 'Smart'
collection_type = models.CharField(max_length=24, choices=CollectionType.choices)
definition_json = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
media_items = models.ManyToManyField(MediaItem, through='MediaCollectionItem')
class Meta:
constraints = [
models.UniqueConstraint(fields=['library', 'name'], name='unique_media_collection_name_per_library')
]
class MediaCollectionItem(models.Model):
media_collection = models.ForeignKey(MediaCollection, on_delete=models.CASCADE)
media_item = models.ForeignKey(MediaItem, on_delete=models.CASCADE)
sort_order = models.IntegerField(default=0)
# ------------------------------------------------------------
# channels and programming policy
# ------------------------------------------------------------
class Channel(models.Model):
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
library = models.ForeignKey(Library, on_delete=models.CASCADE)
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=64, unique=True)
channel_number = models.IntegerField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
timezone_name = models.CharField(max_length=64, default='UTC')
class Visibility(models.TextChoices):
PRIVATE = 'private', 'Private'
SHARED = 'shared', 'Shared'
PUBLIC = 'public', 'Public'
visibility = models.CharField(max_length=16, choices=Visibility.choices, default=Visibility.PRIVATE)
class SchedulingMode(models.TextChoices):
TEMPLATE_DRIVEN = 'template_driven', 'Template Driven'
ALGORITHMIC = 'algorithmic', 'Algorithmic'
MIXED = 'mixed', 'Mixed'
scheduling_mode = models.CharField(max_length=24, choices=SchedulingMode.choices, default=SchedulingMode.TEMPLATE_DRIVEN)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['library', 'name'], name='unique_channel_name_per_library'),
models.UniqueConstraint(fields=['library', 'channel_number'], name='unique_channel_number_per_library')
]
class ChannelMember(models.Model):
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
class Role(models.TextChoices):
VIEWER = 'viewer', 'Viewer'
EDITOR = 'editor', 'Editor'
MANAGER = 'manager', 'Manager'
role = models.CharField(max_length=16, choices=Role.choices)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['channel', 'user'], name='unique_channel_member')
]
class ChannelSourceRule(models.Model):
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE, blank=True, null=True)
media_collection = models.ForeignKey(MediaCollection, on_delete=models.CASCADE, blank=True, null=True)
class RuleMode(models.TextChoices):
ALLOW = 'allow', 'Allow'
PREFER = 'prefer', 'Prefer'
AVOID = 'avoid', 'Avoid'
BLOCK = 'block', 'Block'
rule_mode = models.CharField(max_length=24, choices=RuleMode.choices)
weight = models.DecimalField(max_digits=10, decimal_places=4, default=1.0)
max_items_per_day = models.IntegerField(blank=True, null=True)
max_runs_per_day = models.IntegerField(blank=True, null=True)
min_repeat_gap_hours = models.IntegerField(blank=True, null=True)
active_from = models.DateTimeField(blank=True, null=True)
active_to = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.CheckConstraint(
condition=(
models.Q(media_source__isnull=False, media_collection__isnull=True) |
models.Q(media_source__isnull=True, media_collection__isnull=False)
),
name='exactly_one_source_target_channel_source_rule'
)
]
class CommercialPolicy(models.Model):
name = models.CharField(max_length=255, unique=True)
class Mode(models.TextChoices):
NONE = 'none', 'None'
REPLACE_BREAKS = 'replace_breaks', 'Replace Breaks'
FILL_TO_TARGET = 'fill_to_target', 'Fill to Target'
PROBABILISTIC = 'probabilistic', 'Probabilistic'
mode = models.CharField(max_length=24, choices=Mode.choices)
target_break_seconds = models.IntegerField(blank=True, null=True)
max_break_seconds = models.IntegerField(blank=True, null=True)
allow_same_ad_back_to_back = models.BooleanField(default=False)
description = models.TextField(blank=True, null=True)
class ChannelBranding(models.Model):
channel = models.OneToOneField(Channel, on_delete=models.CASCADE)
commercial_policy = models.ForeignKey(CommercialPolicy, on_delete=models.SET_NULL, blank=True, null=True)
logo_path = models.TextField(blank=True, null=True)
bumper_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True, related_name='branded_channels_bumpers')
fallback_fill_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True, related_name='branded_channels_fallback')
station_id_audio_path = models.TextField(blank=True, null=True)
config_json = models.JSONField(default=dict)
# ------------------------------------------------------------
# schedule templates: recurring editorial structure
# ------------------------------------------------------------
class ScheduleTemplate(models.Model):
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
timezone_name = models.CharField(max_length=64)
valid_from_date = models.DateField(blank=True, null=True)
valid_to_date = models.DateField(blank=True, null=True)
priority = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['channel', 'name'], name='unique_schedule_template_name_per_channel')
]
class ScheduleBlock(models.Model):
schedule_template = models.ForeignKey(ScheduleTemplate, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
class BlockType(models.TextChoices):
PROGRAMMING = 'programming', 'Programming'
COMMERCIAL = 'commercial', 'Commercial'
FILLER = 'filler', 'Filler'
OFF_AIR = 'off_air', 'Off Air'
SPECIAL_EVENT = 'special_event', 'Special Event'
block_type = models.CharField(max_length=24, choices=BlockType.choices)
start_local_time = models.TimeField()
end_local_time = models.TimeField()
day_of_week_mask = models.SmallIntegerField() # 1 to 127
spills_past_midnight = models.BooleanField(default=False)
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
min_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+')
max_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+')
preferred_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True)
preferred_source = models.ForeignKey(MediaSource, on_delete=models.SET_NULL, blank=True, null=True)
class RotationStrategy(models.TextChoices):
SHUFFLE = 'shuffle', 'Shuffle'
SEQUENTIAL = 'sequential', 'Sequential'
LEAST_RECENT = 'least_recent', 'Least Recent'
WEIGHTED_RANDOM = 'weighted_random', 'Weighted Random'
rotation_strategy = models.CharField(max_length=24, choices=RotationStrategy.choices, default=RotationStrategy.SHUFFLE)
class PadStrategy(models.TextChoices):
HARD_STOP = 'hard_stop', 'Hard Stop'
TRUNCATE = 'truncate', 'Truncate'
FILL_WITH_INTERSTITIALS = 'fill_with_interstitials', 'Fill With Interstitials'
ALLOW_OVERRUN = 'allow_overrun', 'Allow Overrun'
pad_strategy = models.CharField(max_length=24, choices=PadStrategy.choices, default=PadStrategy.FILL_WITH_INTERSTITIALS)
created_at = models.DateTimeField(auto_now_add=True)
class BlockSlot(models.Model):
schedule_block = models.ForeignKey(ScheduleBlock, on_delete=models.CASCADE)
slot_order = models.IntegerField()
class SlotKind(models.TextChoices):
FIXED_ITEM = 'fixed_item', 'Fixed Item'
DYNAMIC_PICK = 'dynamic_pick', 'Dynamic Pick'
COMMERCIAL_BREAK = 'commercial_break', 'Commercial Break'
BUMPER = 'bumper', 'Bumper'
STATION_ID = 'station_id', 'Station ID'
slot_kind = models.CharField(max_length=24, choices=SlotKind.choices)
media_item = models.ForeignKey(MediaItem, on_delete=models.SET_NULL, blank=True, null=True)
media_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True)
expected_duration_seconds = models.IntegerField(blank=True, null=True)
max_duration_seconds = models.IntegerField(blank=True, null=True)
is_mandatory = models.BooleanField(default=True)
selection_rule_json = models.JSONField(default=dict)
class Meta:
constraints = [
models.UniqueConstraint(fields=['schedule_block', 'slot_order'], name='unique_slot_order_per_block')
]
# ------------------------------------------------------------
# concrete schedule: actual planned / generated airtimes
# ------------------------------------------------------------
class Airing(models.Model):
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
schedule_template = models.ForeignKey(ScheduleTemplate, on_delete=models.SET_NULL, blank=True, null=True)
schedule_block = models.ForeignKey(ScheduleBlock, on_delete=models.SET_NULL, blank=True, null=True)
media_item = models.ForeignKey(MediaItem, on_delete=models.RESTRICT)
starts_at = models.DateTimeField(db_index=True)
ends_at = models.DateTimeField(db_index=True)
class SlotKind(models.TextChoices):
PROGRAM = 'program', 'Program'
COMMERCIAL = 'commercial', 'Commercial'
BUMPER = 'bumper', 'Bumper'
INTERSTITIAL = 'interstitial', 'Interstitial'
STATION_ID = 'station_id', 'Station ID'
slot_kind = models.CharField(max_length=24, choices=SlotKind.choices)
class Status(models.TextChoices):
SCHEDULED = 'scheduled', 'Scheduled'
PLAYING = 'playing', 'Playing'
PLAYED = 'played', 'Played'
SKIPPED = 'skipped', 'Skipped'
INTERRUPTED = 'interrupted', 'Interrupted'
CANCELLED = 'cancelled', 'Cancelled'
status = models.CharField(max_length=24, choices=Status.choices, default=Status.SCHEDULED, db_index=True)
class SourceReason(models.TextChoices):
TEMPLATE = 'template', 'Template'
AUTOFILL = 'autofill', 'Autofill'
MANUAL = 'manual', 'Manual'
RECOVERY = 'recovery', 'Recovery'
source_reason = models.CharField(max_length=24, choices=SourceReason.choices)
generation_batch_uuid = models.UUIDField()
created_at = models.DateTimeField(auto_now_add=True)
class AiringEvent(models.Model):
airing = models.ForeignKey(Airing, on_delete=models.CASCADE)
class EventType(models.TextChoices):
SCHEDULED = 'scheduled', 'Scheduled'
STARTED = 'started', 'Started'
ENDED = 'ended', 'Ended'
SKIPPED = 'skipped', 'Skipped'
INTERRUPTED = 'interrupted', 'Interrupted'
RESUMED = 'resumed', 'Resumed'
CANCELLED = 'cancelled', 'Cancelled'
event_type = models.CharField(max_length=24, choices=EventType.choices)
event_at = models.DateTimeField(auto_now_add=True)
details_json = models.JSONField(default=dict)
# ------------------------------------------------------------
# viewer state and history
# ------------------------------------------------------------
class UserChannelState(models.Model):
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
is_favorite = models.BooleanField(default=False)
last_tuned_at = models.DateTimeField(blank=True, null=True)
last_known_airing = models.ForeignKey(Airing, on_delete=models.SET_NULL, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['user', 'channel'], name='unique_user_channel_state')
]
class WatchSession(models.Model):
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
channel = models.ForeignKey(Channel, on_delete=models.SET_NULL, blank=True, null=True)
airing = models.ForeignKey(Airing, on_delete=models.SET_NULL, blank=True, null=True)
started_at = models.DateTimeField(db_index=True)
ended_at = models.DateTimeField(blank=True, null=True)
position_seconds = models.PositiveIntegerField(default=0)
client_id = models.CharField(max_length=128, blank=True, null=True)
session_metadata = models.JSONField(default=dict)
class MediaResumePoint(models.Model):
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
media_item = models.ForeignKey(MediaItem, on_delete=models.CASCADE)
resume_seconds = models.PositiveIntegerField()
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['user', 'media_item'], name='unique_media_resume_point')
]

View File

108
core/services/scheduler.py Normal file
View File

@@ -0,0 +1,108 @@
from datetime import datetime, timedelta, date, time, timezone
from core.models import Channel, ScheduleTemplate, ScheduleBlock, Airing, MediaItem
import random
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.
"""
def __init__(self, channel: Channel):
self.channel = channel
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.
"""
# 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.
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
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)
if end_dt <= start_dt:
end_dt += timedelta(days=1)
# Clear existing airings in this window to allow idempotency
Airing.objects.filter(
channel=self.channel,
starts_at__gte=start_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"))
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
return airings_created

127
core/tests.py Normal file
View File

@@ -0,0 +1,127 @@
from django.test import TestCase
from django.db import IntegrityError, transaction
from core.models import (
AppUser, Library, LibraryMember, Genre, ContentRating, MediaSource,
Series, MediaItem, Channel, ScheduleTemplate, ScheduleBlock, Airing
)
from datetime import time
from django.utils import timezone
from uuid import uuid4
class PytvCoreTests(TestCase):
def setUp(self):
self.user1 = AppUser.objects.create_user(username="testuser1", email="test1@example.com", password="password123")
self.user2 = AppUser.objects.create_user(username="testuser2", email="test2@example.com", password="password123")
def test_user_and_library_creation(self):
# Create library
lib = Library.objects.create(owner_user=self.user1, name="My Movies")
self.assertEqual(lib.visibility, Library.Visibility.PRIVATE)
# Test unique constraint: Cannot have two libraries with the same name for the same user
with transaction.atomic():
with self.assertRaises(IntegrityError):
Library.objects.create(owner_user=self.user1, name="My Movies")
# Different user can have the same library name
lib2 = Library.objects.create(owner_user=self.user2, name="My Movies")
self.assertEqual(lib2.name, "My Movies")
def test_media_source_and_item(self):
lib = Library.objects.create(owner_user=self.user1, name="My Movies")
source = MediaSource.objects.create(
library=lib,
name="Local NAS",
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
uri="/mnt/nas/movies"
)
item = MediaItem.objects.create(
media_source=source,
title="Cool Movie",
item_kind=MediaItem.ItemKind.MOVIE,
runtime_seconds=7200,
file_path="/mnt/nas/movies/cool_movie.mp4"
)
self.assertEqual(item.runtime_seconds, 7200)
# Test unique constraint (same media source, same path)
with transaction.atomic():
with self.assertRaises(IntegrityError):
MediaItem.objects.create(
media_source=source,
title="Another Movie",
item_kind=MediaItem.ItemKind.MOVIE,
runtime_seconds=5000,
file_path="/mnt/nas/movies/cool_movie.mp4"
)
# Test M2M Genre works beautifully
genre1 = Genre.objects.create(name="Action")
genre2 = Genre.objects.create(name="Sci-Fi")
item.genres.add(genre1, genre2)
self.assertEqual(item.genres.count(), 2)
def test_channel_and_scheduling(self):
lib = Library.objects.create(owner_user=self.user1, name="TV Network")
channel = Channel.objects.create(
owner_user=self.user1,
library=lib,
name="Channel 1",
slug="ch-1",
channel_number=1
)
template = ScheduleTemplate.objects.create(
channel=channel,
name="Weekday Prime Time",
timezone_name="America/New_York"
)
block = ScheduleBlock.objects.create(
schedule_template=template,
name="8PM Movie",
block_type=ScheduleBlock.BlockType.PROGRAMMING,
start_local_time=time(20, 0),
end_local_time=time(22, 0),
day_of_week_mask=62 # Mon-Fri bitmask
)
self.assertEqual(block.pad_strategy, ScheduleBlock.PadStrategy.FILL_WITH_INTERSTITIALS)
def test_airing_constraints(self):
lib = Library.objects.create(owner_user=self.user1, name="TV Network")
source = MediaSource.objects.create(
library=lib,
name="Local NAS",
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
uri="/mnt/nas/tv"
)
item = MediaItem.objects.create(
media_source=source,
title="Promo",
item_kind=MediaItem.ItemKind.BUMPER,
runtime_seconds=15,
file_path="/mnt/nas/tv/promo.mp4"
)
channel = Channel.objects.create(
owner_user=self.user1,
library=lib,
name="Promo Channel",
slug="promo-ch",
channel_number=99
)
now = timezone.now()
airing = Airing.objects.create(
channel=channel,
media_item=item,
starts_at=now,
ends_at=now + timezone.timedelta(seconds=15),
slot_kind=Airing.SlotKind.BUMPER,
source_reason=Airing.SourceReason.MANUAL,
generation_batch_uuid=uuid4()
)
self.assertEqual(airing.status, Airing.Status.SCHEDULED)

3
core/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.