forked from rc/aircox
480 lines
16 KiB
Python
480 lines
16 KiB
Python
import calendar
|
|
from collections import OrderedDict
|
|
import datetime
|
|
from enum import IntEnum
|
|
import logging
|
|
import os
|
|
import shutil
|
|
|
|
import pytz
|
|
from django.conf import settings as conf
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import F, Q
|
|
from django.db.models.functions import Concat, Substr
|
|
from django.utils import timezone as tz
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.utils.functional import cached_property
|
|
|
|
from aircox import settings, utils
|
|
from .page import Page, PageQuerySet
|
|
from .station import Station
|
|
|
|
|
|
logger = logging.getLogger('aircox')
|
|
|
|
|
|
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule',
|
|
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet']
|
|
|
|
|
|
class ProgramQuerySet(PageQuerySet):
|
|
def station(self, station):
|
|
# FIXME: reverse-lookup
|
|
return self.filter(station=station)
|
|
|
|
def active(self):
|
|
return self.filter(active=True)
|
|
|
|
|
|
class Program(Page):
|
|
"""
|
|
A Program can either be a Streamed or a Scheduled program.
|
|
|
|
A Streamed program is used to generate non-stop random playlists when there
|
|
is not scheduled diffusion. In such a case, a Stream is used to describe
|
|
diffusion informations.
|
|
|
|
A Scheduled program has a schedule and is the one with a normal use case.
|
|
|
|
Renaming a Program rename the corresponding directory to matches the new
|
|
name if it does not exists.
|
|
"""
|
|
# explicit foreign key in order to avoid related name clashes
|
|
station = models.ForeignKey(Station, models.CASCADE,
|
|
verbose_name=_('station'))
|
|
active = models.BooleanField(
|
|
_('active'),
|
|
default=True,
|
|
help_text=_('if not checked this program is no longer active')
|
|
)
|
|
sync = models.BooleanField(
|
|
_('syncronise'),
|
|
default=True,
|
|
help_text=_('update later diffusions according to schedule changes')
|
|
)
|
|
|
|
objects = ProgramQuerySet.as_manager()
|
|
detail_url_name = 'program-detail'
|
|
|
|
@property
|
|
def path(self):
|
|
""" Return program's directory path """
|
|
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
|
|
self.slug.replace('-', '_'))
|
|
|
|
@property
|
|
def abspath(self):
|
|
""" Return absolute path to program's dir """
|
|
return os.path.join(conf.MEDIA_ROOT, self.path)
|
|
|
|
@property
|
|
def archives_path(self):
|
|
return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
|
|
|
|
@property
|
|
def excerpts_path(self):
|
|
return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
|
|
|
|
def __init__(self, *kargs, **kwargs):
|
|
super().__init__(*kargs, **kwargs)
|
|
if self.slug:
|
|
self.__initial_path = self.path
|
|
self.__initial_cover = self.cover
|
|
|
|
@classmethod
|
|
def get_from_path(cl, path):
|
|
"""
|
|
Return a Program from the given path. We assume the path has been
|
|
given in a previous time by this model (Program.path getter).
|
|
"""
|
|
if path.startswith(conf.MEDIA_ROOT):
|
|
path = path.replace(conf.MEDIA_ROOT + '/', '')
|
|
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
|
|
|
|
while path[0] == '/':
|
|
path = path[1:]
|
|
|
|
path = path[:path.index('/')]
|
|
return cl.objects.filter(slug=path.replace('_','-')).first()
|
|
|
|
def ensure_dir(self, subdir=None):
|
|
"""
|
|
Make sur the program's dir exists (and optionally subdir). Return True
|
|
if the dir (or subdir) exists.
|
|
"""
|
|
path = os.path.join(self.abspath, subdir) if subdir else \
|
|
self.abspath
|
|
os.makedirs(path, exist_ok=True)
|
|
return os.path.exists(path)
|
|
|
|
class Meta:
|
|
verbose_name = _('Program')
|
|
verbose_name_plural = _('Programs')
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def save(self, *kargs, **kwargs):
|
|
from .sound import Sound
|
|
super().save(*kargs, **kwargs)
|
|
|
|
# TODO: move in signals
|
|
path_ = getattr(self, '__initial_path', None)
|
|
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
|
|
if path_ is not None and path_ != self.path and \
|
|
os.path.exists(abspath) and not os.path.exists(self.abspath):
|
|
logger.info('program #%s\'s dir changed to %s - update it.',
|
|
self.id, self.title)
|
|
|
|
shutil.move(abspath, self.abspath)
|
|
Sound.objects.filter(path__startswith=path_) \
|
|
.update(file=Concat('file', Substr(F('file'), len(path_))))
|
|
|
|
|
|
class ProgramChildQuerySet(PageQuerySet):
|
|
def station(self, station=None, id=None):
|
|
return self.filter(parent__program__station=station) if id is None else \
|
|
self.filter(parent__program__station__id=id)
|
|
|
|
def program(self, program=None, id=None):
|
|
return self.parent(program, id)
|
|
|
|
|
|
class BaseRerunQuerySet(models.QuerySet):
|
|
""" Queryset for BaseRerun (sub)classes. """
|
|
def station(self, station=None, id=None):
|
|
return self.filter(program__station=station) if id is None else \
|
|
self.filter(program__station__id=id)
|
|
|
|
def program(self, program=None, id=None):
|
|
return self.filter(program=program) if id is None else \
|
|
self.filter(program__id=id)
|
|
|
|
def rerun(self):
|
|
return self.filter(initial__isnull=False)
|
|
|
|
def initial(self):
|
|
return self.filter(initial__isnull=True)
|
|
|
|
|
|
class BaseRerun(models.Model):
|
|
"""
|
|
Abstract model offering rerun facilities. Assume `start` is a
|
|
datetime field or attribute implemented by subclass.
|
|
"""
|
|
program = models.ForeignKey(
|
|
Program, models.CASCADE, db_index=True,
|
|
verbose_name=_('related program'),
|
|
)
|
|
initial = models.ForeignKey(
|
|
'self', models.SET_NULL, related_name='rerun_set',
|
|
verbose_name=_('rerun of'),
|
|
limit_choices_to={'initial__isnull': True},
|
|
blank=True, null=True, db_index=True,
|
|
)
|
|
|
|
objects = BaseRerunQuerySet.as_manager()
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def save(self, *args, **kwargs):
|
|
if self.initial is not None:
|
|
self.initial = self.initial.get_initial()
|
|
if self.initial == self:
|
|
self.initial = None
|
|
|
|
if self.is_rerun:
|
|
self.save_rerun()
|
|
else:
|
|
self.save_initial()
|
|
super().save(*args, **kwargs)
|
|
|
|
def save_rerun(self):
|
|
pass
|
|
|
|
def save_initial(self):
|
|
pass
|
|
|
|
@property
|
|
def is_initial(self):
|
|
return self.initial is None
|
|
|
|
@property
|
|
def is_rerun(self):
|
|
return self.initial is not None
|
|
|
|
def get_initial(self):
|
|
""" Return the initial schedule (self or initial) """
|
|
return self if self.initial is None else self.initial.get_initial()
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
if self.initial is not None and self.initial.start >= self.start:
|
|
raise ValidationError({
|
|
'initial': _('rerun must happen after original')
|
|
})
|
|
|
|
|
|
# ? BIG FIXME: self.date is still used as datetime
|
|
class Schedule(BaseRerun):
|
|
"""
|
|
A Schedule defines time slots of programs' diffusions. It can be an initial
|
|
run or a rerun (in such case it is linked to the related schedule).
|
|
"""
|
|
# Frequency for schedules. Basically, it is a mask of bits where each bit is
|
|
# a week. Bits > rank 5 are used for special schedules.
|
|
# Important: the first week is always the first week where the weekday of
|
|
# the schedule is present.
|
|
# For ponctual programs, there is no need for a schedule, only a diffusion
|
|
class Frequency(IntEnum):
|
|
ponctual = 0b000000
|
|
first = 0b000001
|
|
second = 0b000010
|
|
third = 0b000100
|
|
fourth = 0b001000
|
|
last = 0b010000
|
|
first_and_third = 0b000101
|
|
second_and_fourth = 0b001010
|
|
every = 0b011111
|
|
one_on_two = 0b100000
|
|
|
|
date = models.DateField(
|
|
_('date'), help_text=_('date of the first diffusion'),
|
|
)
|
|
time = models.TimeField(
|
|
_('time'), help_text=_('start time'),
|
|
)
|
|
timezone = models.CharField(
|
|
_('timezone'),
|
|
default=tz.get_current_timezone, max_length=100,
|
|
choices=[(x, x) for x in pytz.all_timezones],
|
|
help_text=_('timezone used for the date')
|
|
)
|
|
duration = models.TimeField(
|
|
_('duration'),
|
|
help_text=_('regular duration'),
|
|
)
|
|
frequency = models.SmallIntegerField(
|
|
_('frequency'),
|
|
choices=[(int(y), {
|
|
'ponctual': _('ponctual'),
|
|
'first': _('1st {day} of the month'),
|
|
'second': _('2nd {day} of the month'),
|
|
'third': _('3rd {day} of the month'),
|
|
'fourth': _('4th {day} of the month'),
|
|
'last': _('last {day} of the month'),
|
|
'first_and_third': _('1st and 3rd {day} of the month'),
|
|
'second_and_fourth': _('2nd and 4th {day} of the month'),
|
|
'every': _('every {day}'),
|
|
'one_on_two': _('one {day} on two'),
|
|
}[x]) for x, y in Frequency.__members__.items()],
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _('Schedule')
|
|
verbose_name_plural = _('Schedules')
|
|
|
|
def __str__(self):
|
|
return '{} - {}, {}'.format(
|
|
self.program.title, self.get_frequency_verbose(),
|
|
self.time.strftime('%H:%M')
|
|
)
|
|
|
|
def save_rerun(self, *args, **kwargs):
|
|
self.program = self.initial.program
|
|
self.duration = self.initial.duration
|
|
self.frequency = self.initial.frequency
|
|
|
|
@cached_property
|
|
def tz(self):
|
|
""" Pytz timezone of the schedule. """
|
|
import pytz
|
|
return pytz.timezone(self.timezone)
|
|
|
|
@cached_property
|
|
def start(self):
|
|
""" Datetime of the start (timezone unaware) """
|
|
return tz.datetime.combine(self.date, self.time)
|
|
|
|
@cached_property
|
|
def end(self):
|
|
""" Datetime of the end """
|
|
return self.start + utils.to_timedelta(self.duration)
|
|
|
|
def get_frequency_verbose(self):
|
|
""" Return frequency formated for display """
|
|
from django.template.defaultfilters import date
|
|
return self.get_frequency_display().format(
|
|
day=date(self.date, 'l')
|
|
).capitalize()
|
|
|
|
# initial cached data
|
|
__initial = None
|
|
|
|
def changed(self, fields=['date', 'duration', 'frequency', 'timezone']):
|
|
initial = self._Schedule__initial
|
|
|
|
if not initial:
|
|
return
|
|
|
|
this = self.__dict__
|
|
|
|
for field in fields:
|
|
if initial.get(field) != this.get(field):
|
|
return True
|
|
|
|
return False
|
|
|
|
def normalize(self, date):
|
|
"""
|
|
Return a datetime set to schedule's time for the provided date,
|
|
handling timezone (based on schedule's timezone).
|
|
"""
|
|
date = tz.datetime.combine(date, self.time)
|
|
return self.tz.normalize(self.tz.localize(date))
|
|
|
|
def dates_of_month(self, date):
|
|
""" Return normalized diffusion dates of provided date's month. """
|
|
if self.frequency == Schedule.Frequency.ponctual:
|
|
return []
|
|
|
|
sched_wday, freq = self.date.weekday(), self.frequency
|
|
date = date.replace(day=1)
|
|
|
|
# last of the month
|
|
if freq == Schedule.Frequency.last:
|
|
date = date.replace(
|
|
day=calendar.monthrange(date.year, date.month)[1])
|
|
date_wday = date.weekday()
|
|
|
|
# end of month before the wanted weekday: move one week back
|
|
if date_wday < sched_wday:
|
|
date -= tz.timedelta(days=7)
|
|
date += tz.timedelta(days=sched_wday - date_wday)
|
|
return [self.normalize(date)]
|
|
|
|
# move to the first day of the month that matches the schedule's weekday
|
|
# check on SO#3284452 for the formula
|
|
date_wday, month = date.weekday(), date.month
|
|
date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) -
|
|
date_wday + sched_wday)
|
|
|
|
if freq == Schedule.Frequency.one_on_two:
|
|
# - adjust date with modulo 14 (= 2 weeks in days)
|
|
# - there are max 3 "weeks on two" per month
|
|
if (date - self.date).days % 14:
|
|
date += tz.timedelta(days=7)
|
|
dates = (date + tz.timedelta(days=14*i) for i in range(0, 3))
|
|
else:
|
|
dates = (date + tz.timedelta(days=7*week) for week in range(0, 5)
|
|
if freq & (0b1 << week))
|
|
|
|
return [self.normalize(date) for date in dates if date.month == month]
|
|
|
|
|
|
def _exclude_existing_date(self, dates):
|
|
from .episode import Diffusion
|
|
saved = set(Diffusion.objects.filter(start__in=dates)
|
|
.values_list('start', flat=True))
|
|
return [date for date in dates if date not in saved]
|
|
|
|
|
|
def diffusions_of_month(self, date):
|
|
"""
|
|
Get episodes and diffusions for month of provided date, including
|
|
reruns.
|
|
:returns: tuple([Episode], [Diffusion])
|
|
"""
|
|
from .episode import Diffusion, Episode
|
|
if self.initial is not None or \
|
|
self.frequency == Schedule.Frequency.ponctual:
|
|
return []
|
|
|
|
# dates for self and reruns as (date, initial)
|
|
reruns = [(rerun, rerun.date - self.date)
|
|
for rerun in self.rerun_set.all()]
|
|
|
|
dates = OrderedDict((date, None) for date in self.dates_of_month(date))
|
|
dates.update([(rerun.normalize(date.date() + delta), date)
|
|
for date in dates.keys() for rerun, delta in reruns])
|
|
|
|
# remove dates corresponding to existing diffusions
|
|
saved = set(Diffusion.objects.filter(start__in=dates.keys(),
|
|
program=self.program,
|
|
schedule=self)
|
|
.values_list('start', flat=True))
|
|
|
|
# make diffs
|
|
duration = utils.to_timedelta(self.duration)
|
|
diffusions = {}
|
|
episodes = {}
|
|
|
|
for date, initial in dates.items():
|
|
if date in saved:
|
|
continue
|
|
|
|
if initial is None:
|
|
episode = Episode.from_page(self.program, date=date)
|
|
episode.date = date
|
|
episodes[date] = episode
|
|
else:
|
|
episode = episodes[initial]
|
|
initial = diffusions[initial]
|
|
|
|
diffusions[date] = Diffusion(
|
|
episode=episode, schedule=self, type=Diffusion.TYPE_ON_AIR,
|
|
initial=initial, start=date, end=date+duration
|
|
)
|
|
return episodes.values(), diffusions.values()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# TODO/FIXME: use validators?
|
|
if self.initial is not None and self.date > self.date:
|
|
raise ValueError('initial must be later')
|
|
|
|
|
|
class Stream(models.Model):
|
|
"""
|
|
When there are no program scheduled, it is possible to play sounds
|
|
in order to avoid blanks. A Stream is a Program that plays this role,
|
|
and whose linked to a Stream.
|
|
|
|
All sounds that are marked as good and that are under the related
|
|
program's archive dir are elligible for the sound's selection.
|
|
"""
|
|
program = models.ForeignKey(
|
|
Program, models.CASCADE,
|
|
verbose_name=_('related program'),
|
|
)
|
|
delay = models.TimeField(
|
|
_('delay'), blank=True, null=True,
|
|
help_text=_('minimal delay between two sound plays')
|
|
)
|
|
begin = models.TimeField(
|
|
_('begin'), blank=True, null=True,
|
|
help_text=_('used to define a time range this stream is '
|
|
'played')
|
|
)
|
|
end = models.TimeField(
|
|
_('end'),
|
|
blank=True, null=True,
|
|
help_text=_('used to define a time range this stream is '
|
|
'played')
|
|
)
|
|
|
|
|