diff --git a/anthias_app/admin.py b/anthias_app/admin.py index c925040d8..f2a9510dc 100644 --- a/anthias_app/admin.py +++ b/anthias_app/admin.py @@ -1,6 +1,10 @@ from django.contrib import admin -from anthias_app.models import Asset +from anthias_app.models import ( + Asset, + ScheduleSlot, + ScheduleSlotItem, +) @admin.register(Asset) @@ -21,3 +25,34 @@ class AssetAdmin(admin.ModelAdmin): 'play_order', 'skip_asset_check', ) + + +class ScheduleSlotItemInline(admin.TabularInline): + model = ScheduleSlotItem + extra = 0 + + +@admin.register(ScheduleSlot) +class ScheduleSlotAdmin(admin.ModelAdmin): + list_display = ( + 'slot_id', + 'name', + 'slot_type', + 'time_from', + 'time_to', + 'days_of_week', + 'is_default', + 'sort_order', + ) + inlines = [ScheduleSlotItemInline] + + +@admin.register(ScheduleSlotItem) +class ScheduleSlotItemAdmin(admin.ModelAdmin): + list_display = ( + 'item_id', + 'slot', + 'asset', + 'sort_order', + 'duration_override', + ) diff --git a/anthias_app/migrations/0003_schedule_slots.py b/anthias_app/migrations/0003_schedule_slots.py new file mode 100644 index 000000000..44608dea4 --- /dev/null +++ b/anthias_app/migrations/0003_schedule_slots.py @@ -0,0 +1,127 @@ +from django.db import migrations, models +import django.db.models.deletion + +import anthias_app.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anthias_app', '0002_auto_20241015_1524'), + ] + + operations = [ + migrations.CreateModel( + name='ScheduleSlot', + fields=[ + ( + 'slot_id', + models.TextField( + default=anthias_app.models.generate_asset_id, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ('name', models.TextField(default='')), + ( + 'slot_type', + models.CharField( + choices=[ + ('default', 'Default'), + ('time', 'Time'), + ('event', 'Event'), + ], + default='time', + max_length=10, + ), + ), + ( + 'time_from', + models.TimeField(default='00:00'), + ), + ( + 'time_to', + models.TimeField(default='23:59'), + ), + ( + 'days_of_week', + models.TextField( + default=anthias_app.models._default_all_days, + ), + ), + ( + 'is_default', + models.BooleanField(default=False), + ), + ( + 'start_date', + models.DateField(blank=True, null=True), + ), + ( + 'end_date', + models.DateField(blank=True, null=True), + ), + ( + 'no_loop', + models.BooleanField(default=False), + ), + ('sort_order', models.IntegerField(default=0)), + ], + options={ + 'db_table': 'schedule_slots', + 'ordering': ['sort_order', 'time_from'], + }, + ), + migrations.CreateModel( + name='ScheduleSlotItem', + fields=[ + ( + 'item_id', + models.TextField( + default=anthias_app.models.generate_asset_id, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ('sort_order', models.IntegerField(default=0)), + ( + 'duration_override', + models.BigIntegerField( + blank=True, + help_text=( + 'If set, overrides the asset' + ' duration for this slot.' + ), + null=True, + ), + ), + ( + 'asset', + models.ForeignKey( + on_delete=( + django.db.models.deletion.CASCADE + ), + related_name='slot_items', + to='anthias_app.asset', + ), + ), + ( + 'slot', + models.ForeignKey( + on_delete=( + django.db.models.deletion.CASCADE + ), + related_name='items', + to='anthias_app.scheduleslot', + ), + ), + ], + options={ + 'db_table': 'schedule_slot_items', + 'ordering': ['sort_order'], + 'unique_together': {('slot', 'asset')}, + }, + ), + ] diff --git a/anthias_app/models.py b/anthias_app/models.py index d0dacf2c0..564567581 100644 --- a/anthias_app/models.py +++ b/anthias_app/models.py @@ -1,3 +1,4 @@ +import json import uuid from django.db import models @@ -8,6 +9,17 @@ def generate_asset_id(): return uuid.uuid4().hex +def _default_all_days(): + return '[1,2,3,4,5,6,7]' + + +SLOT_TYPE_CHOICES = [ + ('default', 'Default'), + ('time', 'Time'), + ('event', 'Event'), +] + + class Asset(models.Model): asset_id = models.TextField( primary_key=True, default=generate_asset_id, editable=False @@ -37,3 +49,173 @@ def is_active(self): return self.start_date < current_time < self.end_date return False + + +class ScheduleSlot(models.Model): + """A time-of-day slot in the playback schedule. + + When at least one ScheduleSlot exists the viewer switches to + "schedule mode": only assets linked via ScheduleSlotItem to the + currently-active slot are played. When the table is empty the + viewer falls back to legacy behaviour (asset.is_active()). + + ``is_default=True`` marks the fallback slot whose content plays + whenever no other slot covers the current time. At most one + default slot may exist. + + Overnight slots are supported: if ``time_from > time_to`` the + window wraps past midnight (e.g. 22:00 -> 06:00). + ``days_of_week`` refers to the **start** day of such a slot. + """ + + slot_id = models.TextField( + primary_key=True, + default=generate_asset_id, + editable=False, + ) + name = models.TextField(default='') + slot_type = models.CharField( + max_length=10, + choices=SLOT_TYPE_CHOICES, + default='time', + ) + time_from = models.TimeField(default='00:00') + time_to = models.TimeField(default='23:59') + days_of_week = models.TextField(default=_default_all_days) + is_default = models.BooleanField(default=False) + start_date = models.DateField(null=True, blank=True) + end_date = models.DateField(null=True, blank=True) + no_loop = models.BooleanField(default=False) + sort_order = models.IntegerField(default=0) + + class Meta: + db_table = 'schedule_slots' + ordering = ['sort_order', 'time_from'] + + def __str__(self): + if self.is_default: + return f'{self.name} (default)' + if self.slot_type == 'event': + return f'{self.name} (event @ {self.time_from})' + return f'{self.name} {self.time_from}-{self.time_to}' + + def get_days_of_week(self): + """Return days_of_week as a Python list of ints.""" + if isinstance(self.days_of_week, list): + return self.days_of_week + try: + return json.loads(self.days_of_week) + except (TypeError, json.JSONDecodeError): + return [1, 2, 3, 4, 5, 6, 7] + + @property + def is_overnight(self): + """True when the slot wraps past midnight.""" + return self.time_from > self.time_to + + def is_currently_active(self): + """Return True if this slot covers the current local time.""" + if self.is_default: + return False + + now = timezone.localtime() + current_time = now.time() + current_weekday = now.isoweekday() + + if self.slot_type == 'event': + return self._is_event_active( + now, + current_time, + current_weekday, + ) + + days = self.get_days_of_week() + + if not self.is_overnight: + return ( + current_weekday in days + and self.time_from <= current_time < self.time_to + ) + + if current_time >= self.time_from: + return current_weekday in days + elif current_time < self.time_to: + yesterday = current_weekday - 1 if current_weekday > 1 else 7 + return yesterday in days + + return False + + def _is_event_active( + self, + now, + current_time, + current_weekday, + ): + """Check if an event slot is currently active. + + Supports three recurrence modes: + - One-time: start_date set, end_date null -> only that date. + - Daily/recurring with range: start_date + end_date. + - Weekly (selected days): days_of_week subset. + """ + today = now.date() + + if self.start_date and self.end_date: + if not (self.start_date <= today <= self.end_date): + return False + elif self.start_date: + if today != self.start_date: + return False + elif self.end_date: + if today > self.end_date: + return False + + days = self.get_days_of_week() + if days and current_weekday not in days: + return False + + return self.time_from <= current_time < self.time_to + + +class ScheduleSlotItem(models.Model): + """Links an Asset to a ScheduleSlot with optional duration override. + + The same asset may appear in multiple *different* slots but only + once per slot (enforced by ``unique_together``). + """ + + item_id = models.TextField( + primary_key=True, + default=generate_asset_id, + editable=False, + ) + slot = models.ForeignKey( + ScheduleSlot, + on_delete=models.CASCADE, + related_name='items', + ) + asset = models.ForeignKey( + Asset, + on_delete=models.CASCADE, + related_name='slot_items', + ) + sort_order = models.IntegerField(default=0) + duration_override = models.BigIntegerField( + blank=True, + null=True, + help_text=('If set, overrides the asset duration for this slot.'), + ) + + class Meta: + db_table = 'schedule_slot_items' + ordering = ['sort_order'] + unique_together = [['slot', 'asset']] + + def __str__(self): + return f'{self.slot.name} -> {self.asset.name}' + + @property + def effective_duration(self): + if self.duration_override is not None: + return self.duration_override + return self.asset.duration diff --git a/api/serializers/schedule.py b/api/serializers/schedule.py new file mode 100644 index 000000000..3a553d2bd --- /dev/null +++ b/api/serializers/schedule.py @@ -0,0 +1,281 @@ +import json + +from drf_spectacular.utils import OpenApiTypes, extend_schema_field +from rest_framework import serializers + +from anthias_app.models import ScheduleSlot, ScheduleSlotItem + + +class ScheduleSlotItemSerializer(serializers.ModelSerializer): + asset_name = serializers.CharField( + source='asset.name', + read_only=True, + ) + asset_uri = serializers.CharField( + source='asset.uri', + read_only=True, + ) + asset_mimetype = serializers.CharField( + source='asset.mimetype', + read_only=True, + ) + asset_duration = serializers.IntegerField( + source='asset.duration', + read_only=True, + ) + effective_duration = serializers.SerializerMethodField() + + class Meta: + model = ScheduleSlotItem + fields = [ + 'item_id', + 'slot_id', + 'asset_id', + 'sort_order', + 'duration_override', + 'asset_name', + 'asset_uri', + 'asset_mimetype', + 'asset_duration', + 'effective_duration', + ] + read_only_fields = [ + 'item_id', + 'effective_duration', + ] + + @extend_schema_field(OpenApiTypes.INT) + def get_effective_duration(self, obj): + if obj.duration_override is not None: + return obj.duration_override + return obj.asset.duration + + +class ScheduleSlotSerializer(serializers.ModelSerializer): + items = ScheduleSlotItemSerializer( + many=True, + read_only=True, + ) + is_currently_active = serializers.SerializerMethodField() + + class Meta: + model = ScheduleSlot + fields = [ + 'slot_id', + 'name', + 'slot_type', + 'time_from', + 'time_to', + 'days_of_week', + 'is_default', + 'start_date', + 'end_date', + 'no_loop', + 'sort_order', + 'items', + 'is_currently_active', + ] + read_only_fields = [ + 'slot_id', + 'items', + 'is_currently_active', + ] + + @extend_schema_field(OpenApiTypes.BOOL) + def get_is_currently_active(self, obj): + return obj.is_currently_active() + + def to_internal_value(self, data): + """Normalise days_of_week from list -> JSON string.""" + data = data.copy() if hasattr(data, 'copy') else dict(data) + dow = data.get('days_of_week') + if isinstance(dow, list): + data['days_of_week'] = json.dumps(dow) + return super().to_internal_value(data) + + def to_representation(self, instance): + """Deserialise days_of_week from JSON string -> list.""" + ret = super().to_representation(instance) + raw = ret.get('days_of_week', '[]') + if isinstance(raw, str): + try: + ret['days_of_week'] = json.loads(raw) + except (TypeError, json.JSONDecodeError): + ret['days_of_week'] = [1, 2, 3, 4, 5, 6, 7] + return ret + + def validate_days_of_week(self, value): + """Ensure days_of_week is a valid JSON array of ints 1-7. + + Empty list is allowed for event slots (one-time events). + """ + if isinstance(value, list): + days = value + else: + try: + days = json.loads(value) + except (TypeError, json.JSONDecodeError): + raise serializers.ValidationError( + 'days_of_week must be a JSON array of integers 1-7.' + ) + + if not isinstance(days, list): + raise serializers.ValidationError('days_of_week must be a list.') + for d in days: + if not isinstance(d, int) or d < 1 or d > 7: + raise serializers.ValidationError( + f'Invalid day: {d}. Must be 1 (Mon) - 7 (Sun).' + ) + + return json.dumps(sorted(set(days))) + + def validate_is_default(self, value): + if not value: + return value + qs = ScheduleSlot.objects.filter(is_default=True) + if self.instance: + qs = qs.exclude(slot_id=self.instance.slot_id) + if qs.exists(): + raise serializers.ValidationError( + 'A default slot already exists. Only one is allowed.' + ) + return value + + def validate(self, attrs): + slot_type = attrs.get( + 'slot_type', + (self.instance.slot_type if self.instance else 'time'), + ) + is_default = attrs.get( + 'is_default', + (self.instance.is_default if self.instance else False), + ) + + if slot_type == 'event' and is_default: + raise serializers.ValidationError( + 'Event slots cannot be marked as default.' + ) + + if slot_type == 'default': + attrs['is_default'] = True + elif slot_type == 'event': + attrs['is_default'] = False + attrs['no_loop'] = True + + if is_default or slot_type == 'default': + return attrs + + time_from = attrs.get( + 'time_from', + (self.instance.time_from if self.instance else None), + ) + + if slot_type == 'event': + if time_from is None: + raise serializers.ValidationError( + 'time_from is required for event slots.' + ) + if not self.instance: + attrs['time_to'] = time_from + return attrs + + time_to = attrs.get( + 'time_to', + (self.instance.time_to if self.instance else None), + ) + days_of_week_raw = attrs.get( + 'days_of_week', + ( + self.instance.days_of_week + if self.instance + else '[1,2,3,4,5,6,7]' + ), + ) + + if time_from is None or time_to is None: + raise serializers.ValidationError( + 'time_from and time_to are required for non-default slots.' + ) + + if time_from == time_to: + raise serializers.ValidationError( + 'time_from and time_to must be different.' + ) + + if isinstance(days_of_week_raw, str): + new_days = set(json.loads(days_of_week_raw)) + else: + new_days = set(days_of_week_raw) + + existing = ScheduleSlot.objects.filter( + is_default=False, + slot_type='time', + ) + if self.instance: + existing = existing.exclude( + slot_id=self.instance.slot_id, + ) + + for slot in existing: + slot_days = set(slot.get_days_of_week()) + common_days = new_days & slot_days + if not common_days: + continue + if _time_ranges_overlap( + (time_from, time_to), + (slot.time_from, slot.time_to), + ): + raise serializers.ValidationError( + f'Time range overlaps with slot ' + f'"{slot.name}" ' + f'({slot.time_from}-{slot.time_to}) ' + f'on shared days.' + ) + + return attrs + + +class CreateScheduleSlotItemSerializer(serializers.Serializer): + """Serializer for adding an asset to a slot.""" + + asset_id = serializers.CharField() + sort_order = serializers.IntegerField( + required=False, + default=0, + ) + duration_override = serializers.IntegerField( + required=False, + default=None, + allow_null=True, + ) + + +class ReorderSlotItemsSerializer(serializers.Serializer): + """Serializer for reordering items within a slot.""" + + ids = serializers.ListField( + child=serializers.CharField(), + ) + + +def _time_ranges_overlap(range_a, range_b): + """Check if two (time_from, time_to) intervals overlap. + + Handles overnight slots where time_from > time_to. + Converts to minute-based intervals within a 0-1440 range, + splitting overnight ranges into two segments. + """ + + def expand(tf, tt): + sf = tf.hour * 60 + tf.minute + st = tt.hour * 60 + tt.minute + if sf < st: + return [(sf, st)] + else: + return [(sf, 1440), (0, st)] + + for a_start, a_end in expand(*range_a): + for b_start, b_end in expand(*range_b): + if a_start < b_end and b_start < a_end: + return True + return False diff --git a/api/tests/test_schedule_endpoints.py b/api/tests/test_schedule_endpoints.py new file mode 100644 index 000000000..f430787aa --- /dev/null +++ b/api/tests/test_schedule_endpoints.py @@ -0,0 +1,326 @@ +"""Tests for schedule slot API endpoints.""" + +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from anthias_app.models import ( + Asset, + ScheduleSlot, + ScheduleSlotItem, +) + + +class ScheduleSlotAPITest(TestCase): + def setUp(self): + self.client = APIClient() + self.asset = Asset.objects.create( + name='Test Asset', + uri='https://example.com', + mimetype='web', + duration=10, + is_enabled=True, + start_date=timezone.now() - timedelta(days=1), + end_date=timezone.now() + timedelta(days=1), + ) + + def test_list_slots_empty(self): + url = reverse('api:schedule_slot_list') + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertEqual(response.data, []) + + def test_create_time_slot(self): + url = reverse('api:schedule_slot_list') + response = self.client.post( + url, + { + 'name': 'Morning', + 'slot_type': 'time', + 'time_from': '09:00', + 'time_to': '12:00', + }, + ) + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + ) + self.assertEqual(response.data['name'], 'Morning') + self.assertEqual(response.data['slot_type'], 'time') + + def test_create_default_slot(self): + url = reverse('api:schedule_slot_list') + response = self.client.post( + url, + { + 'name': 'Fallback', + 'slot_type': 'default', + 'is_default': True, + }, + ) + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + ) + self.assertTrue(response.data['is_default']) + + def test_only_one_default_allowed(self): + url = reverse('api:schedule_slot_list') + self.client.post( + url, + { + 'name': 'Default 1', + 'slot_type': 'default', + 'is_default': True, + }, + ) + response = self.client.post( + url, + { + 'name': 'Default 2', + 'slot_type': 'default', + 'is_default': True, + }, + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + ) + + def test_get_slot_detail(self): + slot = ScheduleSlot.objects.create( + name='Test', + slot_type='time', + time_from='08:00', + time_to='10:00', + ) + url = reverse( + 'api:schedule_slot_detail', + kwargs={'slot_id': slot.slot_id}, + ) + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertEqual(response.data['name'], 'Test') + + def test_update_slot(self): + slot = ScheduleSlot.objects.create( + name='Old', + slot_type='time', + time_from='08:00', + time_to='10:00', + ) + url = reverse( + 'api:schedule_slot_detail', + kwargs={'slot_id': slot.slot_id}, + ) + response = self.client.put( + url, + {'name': 'New'}, + format='json', + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertEqual(response.data['name'], 'New') + + def test_delete_slot(self): + slot = ScheduleSlot.objects.create( + name='Delete Me', + slot_type='time', + time_from='08:00', + time_to='10:00', + ) + url = reverse( + 'api:schedule_slot_detail', + kwargs={'slot_id': slot.slot_id}, + ) + response = self.client.delete(url) + self.assertEqual( + response.status_code, + status.HTTP_204_NO_CONTENT, + ) + self.assertFalse( + ScheduleSlot.objects.filter( + slot_id=slot.slot_id, + ).exists(), + ) + + def test_slot_not_found(self): + url = reverse( + 'api:schedule_slot_detail', + kwargs={'slot_id': 'nonexistent'}, + ) + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_404_NOT_FOUND, + ) + + +class ScheduleSlotItemAPITest(TestCase): + def setUp(self): + self.client = APIClient() + self.asset = Asset.objects.create( + name='Test Asset', + uri='https://example.com', + mimetype='web', + duration=10, + is_enabled=True, + start_date=timezone.now() - timedelta(days=1), + end_date=timezone.now() + timedelta(days=1), + ) + self.slot = ScheduleSlot.objects.create( + name='Default', + slot_type='default', + is_default=True, + ) + + def test_add_item_to_slot(self): + url = reverse( + 'api:schedule_slot_items', + kwargs={'slot_id': self.slot.slot_id}, + ) + response = self.client.post( + url, + {'asset_id': self.asset.asset_id}, + format='json', + ) + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + ) + self.assertEqual( + response.data['asset_id'], + self.asset.asset_id, + ) + + def test_duplicate_item_rejected(self): + ScheduleSlotItem.objects.create( + slot=self.slot, + asset=self.asset, + ) + url = reverse( + 'api:schedule_slot_items', + kwargs={'slot_id': self.slot.slot_id}, + ) + response = self.client.post( + url, + {'asset_id': self.asset.asset_id}, + format='json', + ) + self.assertEqual( + response.status_code, + status.HTTP_409_CONFLICT, + ) + + def test_list_items(self): + ScheduleSlotItem.objects.create( + slot=self.slot, + asset=self.asset, + ) + url = reverse( + 'api:schedule_slot_items', + kwargs={'slot_id': self.slot.slot_id}, + ) + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertEqual(len(response.data), 1) + + def test_delete_item(self): + item = ScheduleSlotItem.objects.create( + slot=self.slot, + asset=self.asset, + ) + url = reverse( + 'api:schedule_slot_item_detail', + kwargs={ + 'slot_id': self.slot.slot_id, + 'item_id': item.item_id, + }, + ) + response = self.client.delete(url) + self.assertEqual( + response.status_code, + status.HTTP_204_NO_CONTENT, + ) + + def test_reorder_items(self): + asset2 = Asset.objects.create( + name='Asset 2', + uri='https://example2.com', + mimetype='web', + duration=15, + is_enabled=True, + start_date=timezone.now() - timedelta(days=1), + end_date=timezone.now() + timedelta(days=1), + ) + item1 = ScheduleSlotItem.objects.create( + slot=self.slot, + asset=self.asset, + sort_order=0, + ) + item2 = ScheduleSlotItem.objects.create( + slot=self.slot, + asset=asset2, + sort_order=1, + ) + url = reverse( + 'api:schedule_slot_items_order', + kwargs={'slot_id': self.slot.slot_id}, + ) + response = self.client.post( + url, + {'ids': [item2.item_id, item1.item_id]}, + format='json', + ) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + item1.refresh_from_db() + item2.refresh_from_db() + self.assertEqual(item2.sort_order, 0) + self.assertEqual(item1.sort_order, 1) + + +class ScheduleStatusAPITest(TestCase): + def setUp(self): + self.client = APIClient() + + def test_status_no_slots(self): + url = reverse('api:schedule_status') + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertFalse(response.data['schedule_enabled']) + + def test_status_with_default_slot(self): + ScheduleSlot.objects.create( + name='Default', + slot_type='default', + is_default=True, + ) + url = reverse('api:schedule_status') + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertTrue(response.data['schedule_enabled']) + self.assertTrue(response.data['using_default']) diff --git a/api/urls/v2.py b/api/urls/v2.py index db423735b..ee5230709 100644 --- a/api/urls/v2.py +++ b/api/urls/v2.py @@ -1,5 +1,13 @@ from django.urls import path +from api.views.schedule import ( + ScheduleSlotDetailView, + ScheduleSlotItemDetailView, + ScheduleSlotItemListView, + ScheduleSlotItemOrderView, + ScheduleSlotListView, + ScheduleStatusView, +) from api.views.v2 import ( AssetContentViewV2, AssetListViewV2, @@ -19,7 +27,11 @@ def get_url_patterns(): return [ - path('v2/assets', AssetListViewV2.as_view(), name='asset_list_v2'), + path( + 'v2/assets', + AssetListViewV2.as_view(), + name='asset_list_v2', + ), path( 'v2/assets/order', PlaylistOrderViewV2.as_view(), @@ -35,11 +47,31 @@ def get_url_patterns(): AssetViewV2.as_view(), name='asset_detail_v2', ), - path('v2/backup', BackupViewV2.as_view(), name='backup_v2'), - path('v2/recover', RecoverViewV2.as_view(), name='recover_v2'), - path('v2/reboot', RebootViewV2.as_view(), name='reboot_v2'), - path('v2/shutdown', ShutdownViewV2.as_view(), name='shutdown_v2'), - path('v2/file_asset', FileAssetViewV2.as_view(), name='file_asset_v2'), + path( + 'v2/backup', + BackupViewV2.as_view(), + name='backup_v2', + ), + path( + 'v2/recover', + RecoverViewV2.as_view(), + name='recover_v2', + ), + path( + 'v2/reboot', + RebootViewV2.as_view(), + name='reboot_v2', + ), + path( + 'v2/shutdown', + ShutdownViewV2.as_view(), + name='shutdown_v2', + ), + path( + 'v2/file_asset', + FileAssetViewV2.as_view(), + name='file_asset_v2', + ), path( 'v2/assets//content', AssetContentViewV2.as_view(), @@ -60,4 +92,35 @@ def get_url_patterns(): IntegrationsViewV2.as_view(), name='integrations_v2', ), + # -- Schedule slots -- + path( + 'v2/schedule/slots', + ScheduleSlotListView.as_view(), + name='schedule_slot_list', + ), + path( + 'v2/schedule/slots/', + ScheduleSlotDetailView.as_view(), + name='schedule_slot_detail', + ), + path( + 'v2/schedule/slots//items', + ScheduleSlotItemListView.as_view(), + name='schedule_slot_items', + ), + path( + 'v2/schedule/slots//items/order', + ScheduleSlotItemOrderView.as_view(), + name='schedule_slot_items_order', + ), + path( + 'v2/schedule/slots//items/', + ScheduleSlotItemDetailView.as_view(), + name='schedule_slot_item_detail', + ), + path( + 'v2/schedule/status', + ScheduleStatusView.as_view(), + name='schedule_status', + ), ] diff --git a/api/views/schedule.py b/api/views/schedule.py new file mode 100644 index 000000000..aca7a1e69 --- /dev/null +++ b/api/views/schedule.py @@ -0,0 +1,472 @@ +import logging +from datetime import datetime, timedelta + +from django.utils import timezone +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from anthias_app.models import Asset, ScheduleSlot, ScheduleSlotItem +from api.serializers.schedule import ( + CreateScheduleSlotItemSerializer, + ReorderSlotItemsSerializer, + ScheduleSlotItemSerializer, + ScheduleSlotSerializer, +) +from lib.auth import authorized + +logger = logging.getLogger(__name__) + + +def _recalculate_event_time_to(slot): + """Recalculate time_to for event slots based on duration.""" + if slot.slot_type != 'event': + return + total_seconds = sum( + item.effective_duration or 0 + for item in slot.items.select_related('asset').all() + ) + base = datetime.combine(datetime.today(), slot.time_from) + end = base + timedelta(seconds=total_seconds) + slot.time_to = end.time() + slot.save(update_fields=['time_to']) + + +class ScheduleSlotListView(APIView): + """GET: list all schedule slots. POST: create a new slot.""" + + @extend_schema( + summary='List schedule slots', + responses={200: ScheduleSlotSerializer(many=True)}, + ) + @authorized + def get(self, request): + slots = ScheduleSlot.objects.all() + serializer = ScheduleSlotSerializer(slots, many=True) + return Response(serializer.data) + + @extend_schema( + summary='Create schedule slot', + request=ScheduleSlotSerializer, + responses={201: ScheduleSlotSerializer}, + ) + @authorized + def post(self, request): + serializer = ScheduleSlotSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + ) + + +class ScheduleSlotDetailView(APIView): + """GET / PUT / DELETE a single schedule slot.""" + + def _get_slot(self, slot_id): + try: + return ScheduleSlot.objects.get(slot_id=slot_id) + except ScheduleSlot.DoesNotExist: + return None + + @extend_schema( + summary='Get schedule slot', + responses={200: ScheduleSlotSerializer}, + ) + @authorized + def get(self, request, slot_id): + slot = self._get_slot(slot_id) + if slot is None: + return Response( + {'error': 'Slot not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(ScheduleSlotSerializer(slot).data) + + @extend_schema( + summary='Update schedule slot', + request=ScheduleSlotSerializer, + responses={200: ScheduleSlotSerializer}, + ) + @authorized + def put(self, request, slot_id): + slot = self._get_slot(slot_id) + if slot is None: + return Response( + {'error': 'Slot not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = ScheduleSlotSerializer( + slot, + data=request.data, + partial=True, + ) + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + slot = serializer.save() + _recalculate_event_time_to(slot) + return Response(ScheduleSlotSerializer(slot).data) + + @extend_schema( + summary='Partial update schedule slot', + request=ScheduleSlotSerializer, + responses={200: ScheduleSlotSerializer}, + ) + @authorized + def patch(self, request, slot_id): + return self.put(request, slot_id) + + @extend_schema(summary='Delete schedule slot') + @authorized + def delete(self, request, slot_id): + slot = self._get_slot(slot_id) + if slot is None: + return Response( + {'error': 'Slot not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + slot.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ScheduleSlotItemListView(APIView): + """GET: list items in a slot. POST: add an asset to a slot.""" + + def _get_slot(self, slot_id): + try: + return ScheduleSlot.objects.get(slot_id=slot_id) + except ScheduleSlot.DoesNotExist: + return None + + @extend_schema( + summary='List slot items', + responses={ + 200: ScheduleSlotItemSerializer(many=True), + }, + ) + @authorized + def get(self, request, slot_id): + slot = self._get_slot(slot_id) + if slot is None: + return Response( + {'error': 'Slot not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + items = slot.items.select_related('asset').all() + return Response( + ScheduleSlotItemSerializer(items, many=True).data, + ) + + @extend_schema( + summary='Add asset to slot', + request=CreateScheduleSlotItemSerializer, + responses={201: ScheduleSlotItemSerializer}, + ) + @authorized + def post(self, request, slot_id): + slot = self._get_slot(slot_id) + if slot is None: + return Response( + {'error': 'Slot not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = CreateScheduleSlotItemSerializer( + data=request.data, + ) + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + asset_id = serializer.validated_data['asset_id'] + try: + asset = Asset.objects.get(asset_id=asset_id) + except Asset.DoesNotExist: + return Response( + {'error': f'Asset {asset_id} not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + + if ScheduleSlotItem.objects.filter( + slot=slot, + asset=asset, + ).exists(): + return Response( + { + 'error': ('This asset is already in this slot'), + }, + status=status.HTTP_409_CONFLICT, + ) + + sort_order = serializer.validated_data.get( + 'sort_order', + 0, + ) + if sort_order == 0: + max_order = ( + slot.items.order_by('-sort_order') + .values_list('sort_order', flat=True) + .first() + ) + sort_order = (max_order or 0) + 1 + + item = ScheduleSlotItem.objects.create( + slot=slot, + asset=asset, + sort_order=sort_order, + duration_override=serializer.validated_data.get( + 'duration_override', + ), + ) + _recalculate_event_time_to(slot) + return Response( + ScheduleSlotItemSerializer(item).data, + status=status.HTTP_201_CREATED, + ) + + +class ScheduleSlotItemDetailView(APIView): + """PUT / DELETE a single item in a slot.""" + + def _get_item(self, slot_id, item_id): + try: + return ScheduleSlotItem.objects.select_related( + 'asset', + ).get(item_id=item_id, slot_id=slot_id) + except ScheduleSlotItem.DoesNotExist: + return None + + @extend_schema( + summary='Update slot item', + responses={200: ScheduleSlotItemSerializer}, + ) + @authorized + def put(self, request, slot_id, item_id): + item = self._get_item(slot_id, item_id) + if item is None: + return Response( + {'error': 'Item not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + + if 'sort_order' in request.data: + item.sort_order = int(request.data['sort_order']) + if 'duration_override' in request.data: + val = request.data['duration_override'] + item.duration_override = int(val) if val is not None else None + + item.save() + _recalculate_event_time_to(item.slot) + return Response( + ScheduleSlotItemSerializer(item).data, + ) + + @extend_schema( + summary='Partial update slot item', + responses={200: ScheduleSlotItemSerializer}, + ) + @authorized + def patch(self, request, slot_id, item_id): + return self.put(request, slot_id, item_id) + + @extend_schema(summary='Delete slot item') + @authorized + def delete(self, request, slot_id, item_id): + item = self._get_item(slot_id, item_id) + if item is None: + return Response( + {'error': 'Item not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + slot = item.slot + item.delete() + _recalculate_event_time_to(slot) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ScheduleSlotItemOrderView(APIView): + """POST: reorder items within a slot.""" + + @extend_schema( + summary='Reorder slot items', + request=ReorderSlotItemsSerializer, + responses={ + 200: ScheduleSlotItemSerializer(many=True), + }, + ) + @authorized + def post(self, request, slot_id): + try: + slot = ScheduleSlot.objects.get(slot_id=slot_id) + except ScheduleSlot.DoesNotExist: + return Response( + {'error': 'Slot not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = ReorderSlotItemsSerializer( + data=request.data, + ) + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + item_ids = serializer.validated_data['ids'] + for i, item_id in enumerate(item_ids): + ScheduleSlotItem.objects.filter( + item_id=item_id, + slot=slot, + ).update(sort_order=i) + + items = slot.items.select_related('asset').all() + return Response( + ScheduleSlotItemSerializer(items, many=True).data, + ) + + +class ScheduleStatusView(APIView): + """GET: current schedule status.""" + + @extend_schema(summary='Get schedule status') + @authorized + def get(self, request): + slots = list(ScheduleSlot.objects.all()) + + if not slots: + return Response( + { + 'schedule_enabled': False, + 'current_slot': None, + 'next_change_at': None, + 'total_slots': 0, + 'using_default': False, + } + ) + + active_event = None + active_time = None + default_slot = None + for slot in slots: + if slot.is_default: + default_slot = slot + elif slot.slot_type == 'event' and slot.is_currently_active(): + active_event = slot + elif slot.is_currently_active(): + active_time = slot + + active_slot = active_event or active_time or None + using_default = False + if active_slot is None and default_slot is not None: + active_slot = default_slot + using_default = True + + next_change = _calc_next_change(active_slot, slots) + + return Response( + { + 'schedule_enabled': True, + 'current_slot': ( + ScheduleSlotSerializer(active_slot).data + if active_slot + else None + ), + 'next_change_at': ( + next_change.isoformat() if next_change else None + ), + 'total_slots': len(slots), + 'using_default': using_default, + } + ) + + +def _calc_next_change(active_slot, all_slots): + """Calculate when the next slot transition occurs.""" + now = timezone.localtime() + current_time = now.time() + + if active_slot is None: + return _calc_next_slot_start( + [s for s in all_slots if not s.is_default], + now, + ) + + if active_slot.is_default: + return _calc_next_slot_start( + [s for s in all_slots if not s.is_default], + now, + ) + + if active_slot.slot_type == 'event' or not active_slot.is_overnight: + return now.replace( + hour=active_slot.time_to.hour, + minute=active_slot.time_to.minute, + second=active_slot.time_to.second, + microsecond=0, + ) + + if current_time >= active_slot.time_from: + tomorrow = now + timedelta(days=1) + return tomorrow.replace( + hour=active_slot.time_to.hour, + minute=active_slot.time_to.minute, + second=0, + microsecond=0, + ) + else: + return now.replace( + hour=active_slot.time_to.hour, + minute=active_slot.time_to.minute, + second=0, + microsecond=0, + ) + + +def _calc_next_slot_start(non_default_slots, now): + """Find the nearest future moment when any slot starts.""" + candidates = [] + for slot in non_default_slots: + days = slot.get_days_of_week() + + if not days and getattr(slot, 'start_date', None): + candidate = now.replace( + year=slot.start_date.year, + month=slot.start_date.month, + day=slot.start_date.day, + hour=slot.time_from.hour, + minute=slot.time_from.minute, + second=0, + microsecond=0, + ) + if candidate > now: + candidates.append(candidate) + continue + + for day_offset in range(8): + check_date = now + timedelta(days=day_offset) + check_weekday = check_date.isoweekday() + if days and check_weekday not in days: + continue + candidate = check_date.replace( + hour=slot.time_from.hour, + minute=slot.time_from.minute, + second=0, + microsecond=0, + ) + if candidate > now: + candidates.append(candidate) + break + + return min(candidates) if candidates else None diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 546e5a06c..336349600 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -6,7 +6,7 @@ from django.test import TestCase from django.utils import timezone -from anthias_app.models import Asset +from anthias_app.models import Asset, ScheduleSlot, ScheduleSlotItem from settings import settings from viewer.scheduling import Scheduler, generate_asset_list @@ -86,26 +86,33 @@ def create_assets(self, assets): for asset in assets: Asset.objects.create(**asset) - def test_generate_asset_list_assets_should_return_list_sorted_by_play_order( + def test_generate_asset_list_assets_should_return_list_sorted_by_play_order( # noqa: E501 self, - ): # noqa: E501 + ): self.create_assets([ASSET_X, ASSET_Y]) - assets, _ = generate_asset_list() + assets, _, _, _ = generate_asset_list() self.assertEqual(assets, [ASSET_Y, ASSET_X]) - def test_generate_asset_list_check_deadline_if_both_active(self): + def test_generate_asset_list_check_deadline_if_both_active( + self, + ): self.create_assets([ASSET_X, ASSET_Y]) - _, deadline = generate_asset_list() + _, deadline, _, _ = generate_asset_list() self.assertEqual(deadline, ASSET_Y['end_date']) - def test_generate_asset_list_check_deadline_if_asset_scheduled(self): - """If ASSET_X is active and ASSET_X[end_date] == (now + 3) and - ASSET_TOMORROW will be active tomorrow then deadline should be - ASSET_TOMORROW[start_date] + def test_generate_asset_list_check_deadline_if_asset_scheduled( + self, + ): + """If ASSET_X is active and ASSET_X[end_date] == (now + 3) + and ASSET_TOMORROW will be active tomorrow then deadline + should be ASSET_TOMORROW[start_date] """ self.create_assets([ASSET_X, ASSET_TOMORROW]) - _, deadline = generate_asset_list() - self.assertEqual(deadline, ASSET_TOMORROW['start_date']) + _, deadline, _, _ = generate_asset_list() + self.assertEqual( + deadline, + ASSET_TOMORROW['start_date'], + ) def test_get_next_asset_should_be_y_and_x(self): self.create_assets([ASSET_X, ASSET_Y]) @@ -114,7 +121,10 @@ def test_get_next_asset_should_be_y_and_x(self): expected_y = scheduler.get_next_asset() expected_x = scheduler.get_next_asset() - self.assertEqual([expected_y, expected_x], [ASSET_Y, ASSET_X]) + self.assertEqual( + [expected_y, expected_x], + [ASSET_Y, ASSET_X], + ) def test_keep_same_position_on_playlist_update(self): self.create_assets([ASSET_X, ASSET_Y]) @@ -126,7 +136,9 @@ def test_keep_same_position_on_playlist_update(self): self.assertEqual(scheduler.index, 1) - def test_counter_should_increment_after_full_asset_loop(self): + def test_counter_should_increment_after_full_asset_loop( + self, + ): settings['shuffle_playlist'] = True self.create_assets([ASSET_X, ASSET_Y]) scheduler = Scheduler() @@ -145,11 +157,15 @@ def test_check_get_db_mtime(self): self.assertEqual(0, Scheduler().get_db_mtime()) - def test_playlist_should_be_updated_after_deadline_reached(self): + def test_playlist_should_be_updated_after_deadline_reached( + self, + ): self.create_assets([ASSET_X, ASSET_Y]) - _, deadline = generate_asset_list() + _, deadline, _, _ = generate_asset_list() - traveller = time_machine.travel(deadline + timedelta(seconds=1)) + traveller = time_machine.travel( + deadline + timedelta(seconds=1), + ) traveller.start() scheduler = Scheduler() @@ -157,3 +173,116 @@ def test_playlist_should_be_updated_after_deadline_reached(self): self.assertEqual([ASSET_X], scheduler.assets) traveller.stop() + + def test_legacy_mode_returns_no_loop_false(self): + """Without schedule slots, no_loop should be False.""" + self.create_assets([ASSET_X]) + _, _, no_loop, slot_id = generate_asset_list() + self.assertFalse(no_loop) + self.assertIsNone(slot_id) + + +class ScheduleSlotTest(TestCase): + """Tests for schedule-mode playlist generation.""" + + def setUp(self): + settings['shuffle_playlist'] = False + self.asset_a = Asset.objects.create(**ASSET_X) + self.asset_b = Asset.objects.create(**ASSET_Y) + + def tearDown(self): + settings['shuffle_playlist'] = False + + def test_default_slot_playlist(self): + """Default slot returns its items when no other active.""" + slot = ScheduleSlot.objects.create( + name='Default', + slot_type='default', + is_default=True, + ) + ScheduleSlotItem.objects.create( + slot=slot, + asset=self.asset_a, + sort_order=0, + ) + playlist, _, no_loop, slot_id = generate_asset_list() + self.assertEqual(len(playlist), 1) + self.assertEqual( + playlist[0]['asset_id'], + self.asset_a.asset_id, + ) + self.assertFalse(no_loop) + self.assertEqual(slot_id, slot.slot_id) + + def test_empty_slots_returns_empty_playlist(self): + """Slots exist but none active and no default -> empty.""" + ScheduleSlot.objects.create( + name='Night', + slot_type='time', + time_from='03:00', + time_to='04:00', + ) + playlist, _, _, slot_id = generate_asset_list() + self.assertEqual(playlist, []) + self.assertIsNone(slot_id) + + def test_no_loop_flag_propagated(self): + """Event slot with no_loop=True propagates the flag.""" + now = timezone.localtime() + slot = ScheduleSlot.objects.create( + name='Event', + slot_type='event', + time_from=(now - timedelta(minutes=5)).time(), + time_to=(now + timedelta(minutes=30)).time(), + no_loop=True, + days_of_week='[]', + ) + ScheduleSlotItem.objects.create( + slot=slot, + asset=self.asset_a, + sort_order=0, + ) + _, _, no_loop, _ = generate_asset_list() + self.assertTrue(no_loop) + + def test_duration_override(self): + """duration_override replaces asset duration in playlist.""" + slot = ScheduleSlot.objects.create( + name='Default', + slot_type='default', + is_default=True, + ) + ScheduleSlotItem.objects.create( + slot=slot, + asset=self.asset_a, + sort_order=0, + duration_override=42, + ) + playlist, _, _, _ = generate_asset_list() + self.assertEqual(playlist[0]['duration'], 42) + + def test_disabled_assets_excluded(self): + """Disabled assets should not appear in schedule.""" + self.asset_a.is_enabled = False + self.asset_a.save() + slot = ScheduleSlot.objects.create( + name='Default', + slot_type='default', + is_default=True, + ) + ScheduleSlotItem.objects.create( + slot=slot, + asset=self.asset_a, + sort_order=0, + ) + ScheduleSlotItem.objects.create( + slot=slot, + asset=self.asset_b, + sort_order=1, + ) + playlist, _, _, _ = generate_asset_list() + self.assertEqual(len(playlist), 1) + self.assertEqual( + playlist[0]['asset_id'], + self.asset_b.asset_id, + ) diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 44eaa38b9..92543b931 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -57,7 +57,7 @@ class TestEmptyPl(ViewerTestCase): @mock.patch('viewer.constants.SERVER_WAIT_TIMEOUT', 0) def test_empty(self): m_asset_list = mock.Mock() - m_asset_list.return_value = ([], None) + m_asset_list.return_value = ([], None, False, None) with mock.patch('viewer.scheduling.generate_asset_list', m_asset_list): self.u.scheduler = Scheduler() diff --git a/viewer/scheduling.py b/viewer/scheduling.py index 4bb43dc0b..f24e2d628 100644 --- a/viewer/scheduling.py +++ b/viewer/scheduling.py @@ -1,12 +1,31 @@ import logging +import threading +from datetime import timedelta from os import path -from random import shuffle +import secrets from django.utils import timezone -from anthias_app.models import Asset +from anthias_app.models import Asset, ScheduleSlot, ScheduleSlotItem from settings import settings +_sysrandom = secrets.SystemRandom() + + +def _secure_shuffle(lst): + """Shuffle list in-place using a cryptographically secure RNG.""" + _sysrandom.shuffle(lst) + + +def _set_time(dt, t, second=0): + """Replace time components on a datetime, zeroing microseconds.""" + return dt.replace( + hour=t.hour, + minute=t.minute, + second=second, + microsecond=0, + ) + def get_specific_asset(asset_id): logging.info('Getting specific asset') @@ -17,13 +36,38 @@ def get_specific_asset(asset_id): return None -def generate_asset_list(): - """Choose deadline via: - 1. Map assets to deadlines with rule: if asset is active then - 'end_date' else 'start_date' - 2. Get nearest deadline +def _asset_to_dict(asset, duration_override=None): + """Convert an Asset to the dict format expected by the viewer.""" + d = {k: v for k, v in asset.__dict__.items() if k not in ['_state', 'md5']} + if duration_override is not None: + d['duration'] = duration_override + return d + + +def generate_asset_list(skip_event_id=None): + """Build the playlist for the viewer. + + If ScheduleSlot records exist the viewer enters "schedule mode": + only assets linked to the currently-active slot are returned. + Otherwise falls back to legacy behaviour (asset.is_active()). + + Returns (playlist, deadline, no_loop, active_slot_id). """ logging.info('Generating asset-list...') + + slots = list(ScheduleSlot.objects.all()) + if slots: + return _generate_schedule_playlist( + slots, + skip_event_id=skip_event_id, + ) + + playlist, deadline = _generate_legacy_playlist() + return playlist, deadline, False, None + + +def _generate_legacy_playlist(): + """Original Anthias playlist generation -- no schedule slots.""" assets = Asset.objects.all() deadlines = [ asset.end_date if asset.is_active() else asset.start_date @@ -36,20 +80,172 @@ def generate_asset_list(): end_date__isnull=False, ).order_by('play_order') playlist = [ - {k: v for k, v in asset.__dict__.items() if k not in ['_state', 'md5']} - for asset in enabled_assets - if asset.is_active() + _asset_to_dict(asset) for asset in enabled_assets if asset.is_active() ] deadline = sorted(deadlines)[0] if len(deadlines) > 0 else None - logging.debug('generate_asset_list deadline: %s', deadline) + logging.debug( + 'legacy playlist: %d assets, deadline %s', + len(playlist), + deadline, + ) if settings['shuffle_playlist']: - shuffle(playlist) + _secure_shuffle(playlist) return playlist, deadline +def _generate_schedule_playlist(slots, skip_event_id=None): + """Schedule-aware playlist: find active slot, return its items. + + Priority: event > time > default. + Returns (playlist, deadline, no_loop, active_slot_id). + """ + active_event = None + active_time = None + default_slot = None + + for slot in slots: + if slot.is_default: + default_slot = slot + elif slot.slot_type == 'event' and slot.is_currently_active(): + if skip_event_id and slot.slot_id == skip_event_id: + continue + if active_event is None: + active_event = slot + elif slot.is_currently_active(): + if active_time is None: + active_time = slot + + active_slot = None + for candidate in [active_event, active_time, default_slot]: + if candidate is None: + continue + has_items = ScheduleSlotItem.objects.filter( + slot=candidate, + ).exists() + if has_items: + active_slot = candidate + break + + if active_slot is None: + active_slot = active_event or active_time or default_slot + + if active_slot is None: + deadline = _calc_next_slot_start( + [s for s in slots if not s.is_default], + ) + logging.info( + 'schedule: no active slot, next start at %s', + deadline, + ) + return [], deadline, False, None + + no_loop = getattr(active_slot, 'no_loop', False) + + logging.info( + 'schedule: active slot "%s" (type=%s, default=%s, no_loop=%s)', + active_slot.name, + getattr(active_slot, 'slot_type', 'time'), + active_slot.is_default, + no_loop, + ) + + items = ( + ScheduleSlotItem.objects.filter(slot=active_slot) + .select_related('asset') + .order_by('sort_order') + ) + + playlist = [] + for item in items: + asset = item.asset + if not asset.is_enabled: + continue + playlist.append( + _asset_to_dict(asset, item.duration_override), + ) + + if not no_loop and settings['shuffle_playlist']: + _secure_shuffle(playlist) + + deadline = _calc_slot_deadline(active_slot, slots) + logging.debug( + 'schedule playlist: %d assets from slot "%s", deadline %s, no_loop %s', + len(playlist), + active_slot.name, + deadline, + no_loop, + ) + + return playlist, deadline, no_loop, active_slot.slot_id + + +def _calc_slot_deadline(active_slot, all_slots): + """When does the current slot end (= time to re-evaluate)?""" + now = timezone.localtime() + current_time = now.time() + + if active_slot.is_default: + return _calc_next_slot_start( + [s for s in all_slots if not s.is_default], + ) + + if getattr(active_slot, 'slot_type', 'time') == 'event': + return _set_time(now, active_slot.time_to, active_slot.time_to.second) + + if active_slot.is_overnight and current_time >= active_slot.time_from: + base = now + timedelta(days=1) + else: + base = now + slot_end = _set_time(base, active_slot.time_to) + + event_slots = [ + s + for s in all_slots + if (getattr(s, 'slot_type', 'time') == 'event' and not s.is_default) + ] + if event_slots: + next_event = _calc_next_slot_start(event_slots) + if next_event and next_event < slot_end: + return next_event + + return slot_end + + +def _calc_next_slot_start(non_default_slots): + """Find the nearest future moment when any slot starts.""" + now = timezone.localtime() + candidates = [] + + for slot in non_default_slots: + days = slot.get_days_of_week() + + if not days and getattr(slot, 'start_date', None): + base = now.replace( + year=slot.start_date.year, + month=slot.start_date.month, + day=slot.start_date.day, + ) + candidate = _set_time(base, slot.time_from) + if candidate > now: + candidates.append(candidate) + continue + + for day_offset in range(8): + check_date = now + timedelta(days=day_offset) + check_weekday = check_date.isoweekday() + if days and check_weekday not in days: + continue + candidate = _set_time(check_date, slot.time_from) + if candidate > now: + candidates.append(candidate) + break + + return min(candidates) if candidates else None + + class Scheduler(object): def __init__(self, *args, **kwargs): logging.debug('Scheduler init') @@ -60,6 +256,11 @@ def __init__(self, *args, **kwargs): self.extra_asset = None self.index = 0 self.reverse = 0 + self.no_loop = False + self.no_loop_done = False + self._active_slot_id = None + self._completed_event_id = None + self._deadline_timer = None self.update_playlist() def get_next_asset(self): @@ -79,6 +280,7 @@ def get_next_asset(self): if not self.assets: self.current_asset_id = None return None + if self.reverse: idx = (self.index - 2) % len(self.assets) self.index = (self.index - 1) % len(self.assets) @@ -87,6 +289,12 @@ def get_next_asset(self): idx = self.index self.index = (self.index + 1) % len(self.assets) + if self.no_loop and self.index == 0: + self.no_loop_done = True + logging.info( + 'Event slot: finished last item, no_loop_done=True', + ) + logging.debug( 'get_next_asset counter %s returning asset %s of %s', self.counter, @@ -94,7 +302,11 @@ def get_next_asset(self): len(self.assets), ) - if settings['shuffle_playlist'] and self.index == 0: + if ( + settings['shuffle_playlist'] + and self.index == 0 + and not self.no_loop + ): self.counter += 1 current_asset = self.assets[idx] @@ -106,44 +318,113 @@ def refresh_playlist(self): time_cur = timezone.now() logging.debug( - 'refresh: counter: (%s) deadline (%s) timecur (%s)', + 'refresh: counter: (%s) deadline (%s) timecur (%s) no_loop (%s)', self.counter, self.deadline, time_cur, + self.no_loop, ) + if self.no_loop and self.no_loop_done: + logging.info( + 'Event slot finished, resuming normal schedule immediately', + ) + self._completed_event_id = self._active_slot_id + self.no_loop = False + self.no_loop_done = False + self.update_playlist(from_event_done=True) + return + if self.get_db_mtime() > self.last_update_db_mtime: - logging.debug('updating playlist due to database modification') + logging.debug( + 'updating playlist due to database modification', + ) self.update_playlist() elif settings['shuffle_playlist'] and self.counter >= 5: self.update_playlist() elif self.deadline and self.deadline <= time_cur: self.update_playlist() - def update_playlist(self): - logging.debug('update_playlist') + def _start_deadline_timer(self): + """Start a timer that fires when the deadline arrives.""" + self._cancel_deadline_timer() + if not self.deadline: + return + now = timezone.now() + delay = (self.deadline - now).total_seconds() + if delay <= 0: + return + logging.info( + 'Deadline timer started: %.1fs until %s', + delay, + self.deadline, + ) + t = threading.Timer(delay, self._on_deadline) + t.daemon = True + t.start() + self._deadline_timer = t + + def _on_deadline(self): + """Called when the deadline timer fires.""" + logging.info( + 'Deadline reached, interrupting current asset for schedule change', + ) + from viewer.playback import skip_event + + skip_event.set() + + def _cancel_deadline_timer(self): + """Cancel the deadline timer if it is active.""" + if self._deadline_timer is not None: + self._deadline_timer.cancel() + self._deadline_timer = None + + def update_playlist(self, from_event_done=False): + logging.debug( + 'update_playlist (from_event_done=%s)', + from_event_done, + ) + self._cancel_deadline_timer() self.last_update_db_mtime = self.get_db_mtime() - (new_assets, new_deadline) = generate_asset_list() - if new_assets == self.assets and new_deadline == self.deadline: - # If nothing changed, don't disturb the current play-through. + + skip_id = self._completed_event_id if from_event_done else None + if not from_event_done: + self._completed_event_id = None + + ( + new_assets, + new_deadline, + new_no_loop, + new_slot_id, + ) = generate_asset_list(skip_event_id=skip_id) + + if ( + new_assets == self.assets + and new_deadline == self.deadline + and new_no_loop == self.no_loop + ): + self._start_deadline_timer() return self.assets, self.deadline = new_assets, new_deadline + self.no_loop = new_no_loop + self._active_slot_id = new_slot_id + self.no_loop_done = False self.counter = 0 - # Try to keep the same position in the play list. E.g., if a new asset - # is added to the end of the list, we don't want to start over from - # the beginning. self.index = self.index % len(self.assets) if self.assets else 0 logging.debug( - 'update_playlist done, count %s, counter %s, index %s, deadline %s', # noqa: E501 + 'update_playlist done, count %s, counter %s, ' + 'index %s, deadline %s, no_loop %s, slot %s', len(self.assets), self.counter, self.index, self.deadline, + self.no_loop, + new_slot_id, ) + self._start_deadline_timer() def get_db_mtime(self): - # get database file last modification time try: return path.getmtime(settings['database']) except (OSError, TypeError):