510 lines
22 KiB
Python
510 lines
22 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)
|
|
fallback_collection = models.ForeignKey('MediaCollection', on_delete=models.SET_NULL, blank=True, null=True, related_name='fallback_for_channels')
|
|
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')
|
|
]
|