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' YOUTUBE_CHANNEL = 'youtube_channel', 'YouTube Channel' YOUTUBE_PLAYLIST = 'youtube_playlist', 'YouTube Playlist' 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) # YouTube-specific: the video ID from yt-dlp youtube_video_id = models.CharField(max_length=64, blank=True, null=True, db_index=True) # Local cache path for downloaded YouTube videos (distinct from file_path which holds source URI) cached_file_path = models.TextField(blank=True, null=True) cache_expires_at = models.DateTimeField(blank=True, null=True) 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) schedule_block_label = models.CharField(max_length=255, 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) class TargetRating(models.IntegerChoices): TV_Y = 1, 'TV-Y / All Children' TV_Y7 = 2, 'TV-Y7 / Directed to Older Children' TV_G = 3, 'TV-G / General Audience' TV_PG = 4, 'TV-PG / Parental Guidance Suggested' TV_14 = 5, 'TV-14 / Parents Strongly Cautioned' TV_MA = 6, 'TV-MA / Mature Audience Only' target_content_rating = models.IntegerField(choices=TargetRating.choices, blank=True, null=True) 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') ]