Files
PYTV/core/models.py
2026-03-08 16:48:58 -04:00

498 lines
21 KiB
Python

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)
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')
]