add episode: models, admin, diffusion gen, sounds_monitor, playlist import

This commit is contained in:
bkfox
2019-07-29 18:58:45 +02:00
parent fff4801ac7
commit 8581743d13
95 changed files with 1976 additions and 17373 deletions

View File

@ -1,5 +1,28 @@
from .base import *
from .diffusion import DiffusionAdmin
from django.contrib import admin
from .episode import DiffusionAdmin, EpisodeAdmin
# from .playlist import PlaylistAdmin
from .program import ProgramAdmin, ScheduleAdmin, StreamAdmin
from .sound import SoundAdmin
from aircox.models import Log, Port, Station
class PortInline(admin.StackedInline):
model = Port
extra = 0
@admin.register(Station)
class StationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
inlines = [PortInline]
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'diffusion', 'sound', 'track']
list_filter = ['date', 'source', 'station']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,91 +0,0 @@
from django import forms
from django.contrib import admin
from django.urls import reverse
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.safestring import mark_safe
from adminsortable2.admin import SortableInlineAdminMixin
from aircox.models import *
class ScheduleInline(admin.TabularInline):
model = Schedule
extra = 1
class StreamInline(admin.TabularInline):
fields = ['delay', 'begin', 'end']
model = Stream
extra = 1
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = ('id', 'program', 'delay', 'begin', 'end')
@admin.register(Program)
class ProgramAdmin(admin.ModelAdmin):
def schedule(self, obj):
return Schedule.objects.filter(program=obj).count() > 0
schedule.boolean = True
schedule.short_description = _("Schedule")
list_display = ('name', 'id', 'active', 'schedule', 'sync', 'station')
fields = ['name', 'slug', 'active', 'station', 'sync']
prepopulated_fields = {'slug': ('name',)}
search_fields = ['name']
inlines = [ScheduleInline, StreamInline]
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_name(self, obj):
return obj.program.name
program_name.short_description = _('Program')
def day(self, obj):
return '' # obj.date.strftime('%A')
day.short_description = _('Day')
def rerun(self, obj):
return obj.initial is not None
rerun.short_description = _('Rerun')
rerun.boolean = True
list_filter = ['frequency', 'program']
list_display = ['id', 'program_name', 'frequency', 'day', 'date',
'time', 'duration', 'timezone', 'rerun']
list_editable = ['time', 'timezone', 'duration']
def get_readonly_fields(self, request, obj=None):
if obj:
return ['program', 'date', 'frequency']
else:
return []
# TODO: sort & redo
class PortInline(admin.StackedInline):
model = Port
extra = 0
@admin.register(Station)
class StationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
inlines = [PortInline]
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'diffusion', 'sound', 'track']
list_filter = ['date', 'source', 'station']
admin.site.register(Port)

View File

@ -9,7 +9,7 @@ from .playlist import TracksInline
class SoundInline(admin.TabularInline):
model = Sound
fk_name = 'diffusion'
fields = ['type', 'path', 'duration','public']
fields = ['type', 'path', 'duration', 'is_public']
readonly_fields = ['type']
extra = 0

82
aircox/admin/episode.py Normal file
View File

@ -0,0 +1,82 @@
import copy
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.models import Episode, Diffusion, Sound, Track
from .page import PageAdmin
from .playlist import TracksInline
class DiffusionBaseAdmin:
fields = ['type', 'start', 'end']
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if not request.user.has_perm('aircox_program.scheduling'):
fields += ['program', 'start', 'end']
return [field for field in fields if field in self.fields]
@admin.register(Diffusion)
class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin):
def start_date(self, obj):
return obj.local_start.strftime('%Y/%m/%d %H:%M')
start_date.short_description = _('start')
def end_date(self, obj):
return obj.local_end.strftime('%H:%M')
end_date.short_description = _('end')
list_display = ('episode', 'start_date', 'end_date', 'type', 'initial')
list_filter = ('type', 'start', 'program')
list_editable = ('type',)
ordering = ('-start', 'id')
fields = ['type', 'start', 'end', 'initial', 'program']
def get_object(self, *args, **kwargs):
"""
We want rerun to redirect to the given object.
"""
obj = super().get_object(*args, **kwargs)
if obj and obj.initial:
obj = obj.initial
return obj
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.GET and len(request.GET):
return qs
return qs.exclude(type=Diffusion.Type.unconfirmed)
class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline):
model = Diffusion
fk_name = 'episode'
extra = 0
def has_add_permission(self, request):
return request.user.has_perm('aircox_program.scheduling')
class SoundInline(admin.TabularInline):
model = Sound
fk_name = 'episode'
fields = ['type', 'path', 'duration', 'is_public']
readonly_fields = ['type']
extra = 0
@admin.register(Episode)
class EpisodeAdmin(PageAdmin):
list_display = PageAdmin.list_display + ('program',)
list_filter = ('program',)
readonly_fields = ('program',)
fieldsets = copy.deepcopy(PageAdmin.fieldsets)
fieldsets[1][1]['fields'].insert(0, 'program')
inlines = [TracksInline, SoundInline, DiffusionInline]

28
aircox/admin/page.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
class PageAdmin(admin.ModelAdmin):
list_display = ('cover_thumb', 'title', 'status')
list_display_links = ('cover_thumb', 'title')
list_editable = ('status',)
prepopulated_fields = {"slug": ("title",)}
fieldsets = [
('', {
'fields': ['title', 'slug', 'cover', 'content'],
}),
(_('Publication Settings'), {
'fields': ['featured', 'allow_comments', 'status'],
'classes': ('collapse',),
}),
]
def cover_thumb(self, obj):
return mark_safe('<img src="{}"/>'.format(obj.cover.icons['64'])) \
if obj.cover else ''

View File

@ -21,19 +21,19 @@ class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all())
list_display = ['pk', 'artist', 'title', 'tag_list', 'diffusion', 'sound', 'timestamp']
list_display = ['pk', 'artist', 'title', 'tag_list', 'episode', 'sound', 'timestamp']
list_editable = ['artist', 'title']
list_filter = ['sound', 'diffusion', 'artist', 'title', 'tags']
list_filter = ['sound', 'episode', 'artist', 'title', 'tags']
fieldsets = [
(_('Playlist'), {'fields': ['diffusion', 'sound', 'position', 'timestamp']}),
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
# TODO on edit: readonly_fields = ['diffusion', 'sound']
# TODO on edit: readonly_fields = ['episode', 'sound']
#@admin.register(Playlist)
#class PlaylistAdmin(admin.ModelAdmin):
# fields = ['diffusion', 'sound']
# fields = ['episode', 'sound']
# inlines = [TracksInline]
# # TODO: dynamic read only fields

75
aircox/admin/program.py Normal file
View File

@ -0,0 +1,75 @@
from copy import deepcopy
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from aircox.models import Program, Schedule, Stream
from .page import PageAdmin
class ScheduleInline(admin.TabularInline):
model = Schedule
extra = 1
class StreamInline(admin.TabularInline):
fields = ['delay', 'begin', 'end']
model = Stream
extra = 1
@admin.register(Program)
class ProgramAdmin(PageAdmin):
def schedule(self, obj):
return Schedule.objects.filter(program=obj).count() > 0
schedule.boolean = True
schedule.short_description = _("Schedule")
list_display = PageAdmin.list_display + ('schedule', 'station')
fieldsets = deepcopy(PageAdmin.fieldsets) + [
(_('Program Settings'), {
'fields': ['active', 'station', 'sync'],
'classes': ('collapse',),
})
]
prepopulated_fields = {'slug': ('title',)}
search_fields = ['title']
inlines = [ScheduleInline, StreamInline]
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_title(self, obj):
return obj.program.title
program_title.short_description = _('Program')
def freq(self, obj):
return obj.get_frequency_verbose()
freq.short_description = _('Day')
def rerun(self, obj):
return obj.initial is not None
rerun.short_description = _('Rerun')
rerun.boolean = True
list_filter = ['frequency', 'program']
list_display = ['program_title', 'freq', 'time', 'timezone', 'duration',
'rerun']
list_editable = ['time', 'duration']
def get_readonly_fields(self, request, obj=None):
if obj:
return ['program', 'date', 'frequency']
else:
return []
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = ('id', 'program', 'delay', 'begin', 'end')

View File

@ -7,12 +7,16 @@ from .playlist import TracksInline
@admin.register(Sound)
class SoundAdmin(admin.ModelAdmin):
def filename(self, obj):
return '/'.join(obj.path.split('/')[-2:])
filename.short_description=_('file')
fields = None
list_display = ['id', 'name', 'program', 'type', 'duration', 'mtime',
'is_public', 'is_good_quality', 'path']
list_display = ['id', 'name', 'program', 'type', 'duration',
'is_public', 'is_good_quality', 'episode', 'filename']
list_filter = ('program', 'type', 'is_good_quality', 'is_public')
fieldsets = [
(None, {'fields': ['name', 'path', 'type', 'program', 'diffusion']}),
(None, {'fields': ['name', 'path', 'type', 'program', 'episode']}),
(None, {'fields': ['embed', 'duration', 'is_public', 'mtime']}),
(None, {'fields': ['is_good_quality']})
]

View File

@ -15,60 +15,57 @@ planified before the (given) month.
- "check" will remove all diffusions that are unconfirmed and have been planified
from the (given) month and later.
"""
import time
import datetime
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone as tz
from aircox.models import *
from aircox.models import Schedule, Diffusion
logger = logging.getLogger('aircox.tools')
class Actions:
@classmethod
def update(cl, date, mode):
manual = (mode == 'manual')
date = None
count = [0, 0]
for schedule in Schedule.objects.filter(program__active=True) \
.order_by('initial'):
# in order to allow rerun links between diffusions, we save items
# by schedule;
items = schedule.diffusions_of_month(date, exclude_saved=True)
count[0] += len(items)
def __init__(self, date):
self.date = date or datetime.date.today()
# we can't bulk create because we need signal processing
for item in items:
conflicts = item.get_conflicts()
item.type = Diffusion.Type.unconfirmed \
if manual or conflicts.count() else \
Diffusion.Type.normal
item.save(no_check=True)
if conflicts.count():
item.conflicts.set(conflicts.all())
def update(self):
episodes, diffusions = [], []
for schedule in Schedule.objects.filter(program__active=True,
initial__isnull=True):
eps, diffs = schedule.diffusions_of_month(self.date)
logger.info('[update] schedule %s: %d new diffusions',
str(schedule), len(items),
)
episodes += eps
diffusions += diffs
logger.info('[update] %d diffusions have been created, %s', count[0],
'do not forget manual approval' if manual else
'{} conflicts found'.format(count[1]))
logger.info('[update] %s: %d episodes, %d diffusions and reruns',
str(schedule), len(eps), len(diffs))
@staticmethod
def clean(date):
with transaction.atomic():
logger.info('[update] save %d episodes and %d diffusions',
len(episodes), len(diffusions))
for episode in episodes:
episode.save()
for diffusion in diffusions:
# force episode id's update
diffusion.episode = diffusion.episode
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
start__lt=date)
start__lt=self.date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete()
@staticmethod
def check(date):
def check(self):
# TODO: redo
qs = Diffusion.objects.filter(type=Diffusion.Type.unconfirmed,
start__gt=date)
start__gt=self.date)
items = []
for diffusion in qs:
schedules = Schedule.objects.filter(program=diffusion.program)
@ -88,21 +85,21 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
now = tz.datetime.today()
today = datetime.date.today()
group = parser.add_argument_group('action')
group.add_argument(
'--update', action='store_true',
'-u', '--update', action='store_true',
help='generate (unconfirmed) diffusions for the given month. '
'These diffusions must be confirmed manually by changing '
'their type to "normal"'
)
group.add_argument(
'--clean', action='store_true',
'-l', '--clean', action='store_true',
help='remove unconfirmed diffusions older than the given month'
)
group.add_argument(
'--check', action='store_true',
'-c', '--check', action='store_true',
help='check unconfirmed later diffusions from the given '
'date agains\'t schedule. If no schedule is found, remove '
'it.'
@ -110,10 +107,10 @@ class Command(BaseCommand):
group = parser.add_argument_group('date')
group.add_argument(
'--year', type=int, default=now.year,
'--year', type=int, default=today.year,
help='used by update, default is today\'s year')
group.add_argument(
'--month', type=int, default=now.month,
'--month', type=int, default=today.month,
help='used by update, default is today\'s month')
group.add_argument(
'--next-month', action='store_true',
@ -121,31 +118,20 @@ class Command(BaseCommand):
' (if next month from today'
)
group = parser.add_argument_group('options')
group.add_argument(
'--mode', type=str, choices=['manual', 'auto'],
default='auto',
help='manual means that all generated diffusions are unconfirmed, '
'thus must be approved manually; auto confirmes all '
'diffusions except those that conflicts with others'
)
def handle(self, *args, **options):
date = tz.datetime(year=options.get('year'),
month=options.get('month'),
day=1)
date = tz.make_aware(date)
date = datetime.date(year=options['year'], month=options['month'],
day=1)
if options.get('next_month'):
month = options.get('month')
date += tz.timedelta(days=28)
if date.month == month:
date += tz.timedelta(days=28)
date = date.replace(day=1)
actions = Actions(date)
if options.get('update'):
Actions.update(date, mode=options.get('mode'))
actions.update()
if options.get('clean'):
Actions.clean(date)
actions.clean()
if options.get('check'):
Actions.check(date)
actions.check()

View File

@ -1,6 +1,6 @@
"""
Import one or more playlist for the given sound. Attach it to the sound
or to the related Diffusion if wanted.
Import one or more playlist for the given sound. Attach it to the provided
sound.
Playlists are in CSV format, where columns are separated with a
'{settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
@ -18,14 +18,15 @@ from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from aircox import settings
from aircox.models import *
import aircox.settings as settings
__doc__ = __doc__.format(settings=settings)
logger = logging.getLogger('aircox.tools')
class Importer:
class PlaylistImport:
path = None
data = None
tracks = None
@ -121,17 +122,12 @@ class Command (BaseCommand):
help='generate a playlist for the sound of the given path. '
'If not given, try to match a sound with the same path.'
)
parser.add_argument(
'--diffusion', '-d', action='store_true',
help='try to get the diffusion relative to the sound if it exists'
)
def handle(self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get('sound'):
sound = Sound.objects.filter(
path__icontains=options.get('sound')
).first()
sound = Sound.objects.filter(path__icontains=options.get('sound'))\
.first()
else:
path_, ext = os.path.splitext(path)
sound = Sound.objects.filter(path__icontains=path_).first()
@ -141,11 +137,10 @@ class Command (BaseCommand):
'{path}'.format(path=path))
return
if options.get('diffusion') and sound.diffusion:
sound = sound.diffusion
importer = Importer(path, sound=sound).run()
# FIXME: auto get sound.episode if any
importer = PlaylistImport(path, sound=sound).run()
for track in importer.tracks:
logger.info('track #{pos} imported: {title}, by {artist}'.format(
pos=track.position, title=track.title, artist=track.artist
))

View File

@ -23,6 +23,7 @@ parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires
Sox (and soxi).
"""
from argparse import RawTextHelpFormatter
import datetime
import atexit
import logging
import os
@ -37,13 +38,21 @@ from django.conf import settings as main_settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from aircox.models import *
import aircox.settings as settings
import aircox.utils as utils
from aircox import settings, utils
from aircox.models import Diffusion, Program, Sound
from .import_playlist import PlaylistImport
logger = logging.getLogger('aircox.tools')
sound_path_re = re.compile(
'^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
'(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?'
'(_(?P<n>[0-9]+))?'
'_?(?P<name>.*)$'
)
class SoundInfo:
name = ''
sound = None
@ -66,33 +75,19 @@ class SoundInfo:
Parse file name to get info on the assumption it has the correct
format (given in Command.help)
"""
file_name = os.path.basename(value)
file_name = os.path.splitext(file_name)[0]
r = re.search('^(?P<year>[0-9]{4})'
'(?P<month>[0-9]{2})'
'(?P<day>[0-9]{2})'
'(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?'
'(_(?P<n>[0-9]+))?'
'_?(?P<name>.*)$',
file_name)
if not (r and r.groupdict()):
r = {'name': file_name}
logger.info('file name can not be parsed -> %s', value)
else:
r = r.groupdict()
name = os.path.splitext(os.path.basename(value))[0]
match = sound_path_re.search(name)
match = match.groupdict() if match and match.groupdict() else \
{'name': name}
self._path = value
self.name = r['name'].replace('_', ' ').capitalize()
self.name = match['name'].replace('_', ' ').capitalize()
for key in ('year', 'month', 'day', 'hour', 'minute'):
value = r.get(key)
if value is not None:
value = int(value)
setattr(self, key, value)
value = match.get(key)
setattr(self, key, int(value) if value is not None else None)
self.n = r.get('n')
return r
self.n = match.get('n')
def __init__(self, path='', sound=None):
self.path = path
@ -116,9 +111,8 @@ class SoundInfo:
(if save is True, sync to DB), and check for a playlist file.
"""
sound, created = Sound.objects.get_or_create(
path=self.path,
defaults=kwargs
)
path=self.path, defaults=kwargs)
if created or sound.check_on_file():
logger.info('sound is new or have been modified -> %s', self.path)
sound.duration = self.get_duration()
@ -139,22 +133,17 @@ class SoundInfo:
if sound.track_set.count():
return
import aircox.management.commands.import_playlist \
as import_playlist
# no playlist, try to retrieve metadata
# import playlist
path = os.path.splitext(self.sound.path)[0] + '.csv'
if not os.path.exists(path):
if use_default:
track = sound.file_metadata()
if track:
track.save()
return
if os.path.exists(path):
PlaylistImport(path, sound=sound).run()
# try metadata
elif use_default:
track = sound.file_metadata()
if track:
track.save()
# else, import
import_playlist.Importer(path, sound=sound).run()
def find_diffusion(self, program, save=True):
def find_episode(self, program, save=True):
"""
For a given program, check if there is an initial diffusion
to associate to, using the date info we have. Update self.sound
@ -163,25 +152,22 @@ class SoundInfo:
We only allow initial diffusion since there should be no
rerun.
"""
if self.year == None or not self.sound or self.sound.diffusion:
if self.year is None or not self.sound or self.sound.episode:
return
if self.hour is None:
date = datetime.date(self.year, self.month, self.day)
else:
date = datetime.datetime(self.year, self.month, self.day,
self.hour or 0, self.minute or 0)
date = tz.datetime(self.year, self.month, self.day,
self.hour or 0, self.minute or 0)
date = tz.get_current_timezone().localize(date)
qs = Diffusion.objects.station(program.station).after(date) \
.filter(program=program, initial__isnull=True)
diffusion = qs.first()
diffusion = program.diffusion_set.initial().at(date).first()
if not diffusion:
return
logger.info('diffusion %s mathes to sound -> %s', str(diffusion),
self.sound.path)
self.sound.diffusion = diffusion
logger.info('%s <--> %s', self.sound.path, str(diffusion.episode))
self.sound.episode = diffusion.episode
if save:
self.sound.save()
return diffusion
@ -219,7 +205,7 @@ class MonitorHandler(PatternMatchingEventHandler):
self.sound_kwargs['program'] = program
si.get_sound(save=True, **self.sound_kwargs)
if si.year is not None:
si.find_diffusion(program)
si.find_episode(program)
si.sound.save(True)
def on_deleted(self, event):
@ -246,7 +232,7 @@ class MonitorHandler(PatternMatchingEventHandler):
if program:
si = SoundInfo(sound.path, sound=sound)
if si.year is not None:
si.find_diffusion(program)
si.find_episode(program)
sound.save()
@ -270,7 +256,7 @@ class Command(BaseCommand):
dirs = []
for program in programs:
logger.info('#%d %s', program.id, program.name)
logger.info('#%d %s', program.id, program.title)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type=Sound.Type.archive,
@ -304,7 +290,7 @@ class Command(BaseCommand):
si = SoundInfo(path)
sound_kwargs['program'] = program
si.get_sound(save=True, **sound_kwargs)
si.find_diffusion(program, save=True)
si.find_episode(program, save=True)
si.find_playlist(si.sound)
sounds.append(si.sound.pk)

View File

@ -216,7 +216,7 @@ class Monitor:
return
qs = Diffusions.objects.station(self.station).at().filter(
type=Diffusion.Type.normal,
type=Diffusion.Type.on_air,
sound__type=Sound.Type.archive,
)
logs = Log.objects.station(station).on_air().with_diff()

View File

@ -153,12 +153,12 @@ class Station(models.Model):
if date:
logs = Log.objects.at(date)
diffs = Diffusion.objects.station(self).at(date) \
.filter(start__lte=now, type=Diffusion.Type.normal) \
.filter(start__lte=now, type=Diffusion.Type.on_air) \
.order_by('-start')
else:
logs = Log.objects
diffs = Diffusion.objects \
.filter(type=Diffusion.Type.normal,
.filter(type=Diffusion.Type.on_air,
start__lte=now) \
.order_by('-start')[:count]
@ -653,7 +653,7 @@ class DiffusionQuerySet(models.QuerySet):
return self.filter(program=program)
def on_air(self):
return self.filter(type=Diffusion.Type.normal)
return self.filter(type=Diffusion.Type.on_air)
def at(self, date=None):
"""
@ -811,7 +811,7 @@ class Diffusion(models.Model):
True if Diffusion is live (False if there are sounds files)
"""
return self.type == self.Type.normal and \
return self.type == self.Type.on_air and \
not self.get_sounds(archive=True).count()
def get_playlist(self, **types):

View File

@ -0,0 +1,8 @@
from .page import Page
from .program import Program, Stream, Schedule
from .episode import Episode, Diffusion
from .log import Log
from .sound import Sound, Track
from .station import Station, Port

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

296
aircox/models/episode.py Normal file
View File

@ -0,0 +1,296 @@
import datetime
from enum import IntEnum
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 ugettext_lazy as _
from django.utils.functional import cached_property
from aircox import settings, utils
from .program import Program, BaseRerun, BaseRerunQuerySet
from .page import Page, PageQuerySet
__all__ = ['Episode', 'EpisodeQuerySet', 'Diffusion', 'DiffusionQuerySet']
class EpisodeQuerySet(PageQuerySet):
def station(self, station):
return self.filter(program__station=station)
# FIXME: useful??? might use program.episode_set
def program(self, program):
return self.filter(program=program)
class Episode(Page):
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('program'),
)
objects = EpisodeQuerySet.as_manager()
class Meta:
verbose_name = _('Episode')
verbose_name_plural = _('Episodes')
def save(self, *args, **kwargs):
if self.cover is None:
self.cover = self.program.cover
super().save(*args, **kwargs)
@classmethod
def from_date(cls, program, date):
title = settings.AIRCOX_EPISODE_TITLE.format(
program=program,
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
)
return cls(program=program, title=title)
class DiffusionQuerySet(BaseRerunQuerySet):
def station(self, station):
return self.filter(episode__program__station=station)
def program(self, program):
return self.filter(program=program)
def on_air(self):
return self.filter(type=Diffusion.Type.on_air)
def at(self, date=None):
"""
Return diffusions occuring at the given date, ordered by +start
If date is a datetime instance, get diffusions that occurs at
the given moment. If date is not a datetime object, it uses
it as a date, and get diffusions that occurs this day.
When date is None, uses tz.now().
"""
# note: we work with localtime
date = utils.date_or_default(date)
qs = self
filters = None
if isinstance(date, datetime.datetime):
# use datetime: we want diffusion that occurs around this
# range
filters = {'start__lte': date, 'end__gte': date}
qs = qs.filter(**filters)
else:
# use date: we want diffusions that occurs this day
qs = qs.filter(Q(start__date=date) | Q(end__date=date))
return qs.order_by('start').distinct()
def after(self, date=None):
"""
Return a queryset of diffusions that happen after the given
date (default: today).
"""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__gte=date)
else:
qs = self.filter(start__date__gte=date)
return qs.order_by('start')
def before(self, date=None):
"""
Return a queryset of diffusions that finish before the given
date (default: today).
"""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__lt=date)
else:
qs = self.filter(start__date__lt=date)
return qs.order_by('start')
def range(self, start, end):
# FIXME can return dates that are out of range...
return self.after(start).before(end)
class Diffusion(BaseRerun):
"""
A Diffusion is an occurrence of a Program that is scheduled on the
station's timetable. It can be a rerun of a previous diffusion. In such
a case, use rerun's info instead of its own.
A Diffusion without any rerun is named Episode (previously, a
Diffusion was different from an Episode, but in the end, an
episode only has a name, a linked program, and a list of sounds, so we
finally merge theme).
A Diffusion can have different types:
- default: simple diffusion that is planified / did occurred
- unconfirmed: a generated diffusion that has not been confirmed and thus
is not yet planified
- cancel: the diffusion has been canceled
- stop: the diffusion has been manually stopped
"""
objects = DiffusionQuerySet.as_manager()
class Type(IntEnum):
on_air = 0x00
unconfirmed = 0x01
canceled = 0x02
episode = models.ForeignKey(
Episode, models.CASCADE,
verbose_name=_('episode'),
)
type = models.SmallIntegerField(
verbose_name=_('type'),
default=Type.on_air,
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
)
start = models.DateTimeField(_('start'))
end = models.DateTimeField(_('end'))
# port = models.ForeignKey(
# 'self',
# verbose_name = _('port'),
# blank = True, null = True,
# on_delete=models.SET_NULL,
# help_text = _('use this input port'),
# )
class Meta:
verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions')
permissions = (
('programming', _('edit the diffusion\'s planification')),
)
def __str__(self):
str_ = '{episode} - {date}'.format(
self=self, episode=self.episode and self.episode.title,
date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
)
if self.initial:
str_ += ' ({})'.format(_('rerun'))
return str_
#def save(self, no_check=False, *args, **kwargs):
#if self.start != self._initial['start'] or \
# self.end != self._initial['end']:
# self.check_conflicts()
def save_rerun(self):
self.episode = self.initial.episode
self.program = self.episode.program
def save_original(self):
self.program = self.episode.program
if self.episode != self._initial['episode']:
self.rerun_set.update(episode=self.episode, program=self.program)
@property
def duration(self):
return self.end - self.start
@property
def date(self):
""" Return diffusion start as a date. """
return utils.cast_date(self.start)
@cached_property
def local_start(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
return tz.localtime(self.start, tz.get_current_timezone())
@property
def local_end(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
return tz.localtime(self.end, tz.get_current_timezone())
@property
def original(self):
""" Return the original diffusion (self or initial) """
return self.initial.original if self.initial else self
# TODO: property?
def is_live(self):
"""
True if Diffusion is live (False if there are sounds files)
"""
return self.type == self.Type.on_air and \
not self.get_sounds(archive=True).count()
def get_playlist(self, **types):
"""
Returns sounds as a playlist (list of *local* archive file path).
The given arguments are passed to ``get_sounds``.
"""
from .sound import Sound
return list(self.get_sounds(**types)
.filter(path__isnull=False, type=Sound.Type.archive)
.values_list('path', flat=True))
def get_sounds(self, **types):
"""
Return a queryset of sounds related to this diffusion,
ordered by type then path.
**types: filter on the given sound types name, as `archive=True`
"""
from .sound import Sound
sounds = (self.initial or self).sound_set.order_by('type', 'path')
_in = [getattr(Sound.Type, name)
for name, value in types.items() if value]
return sounds.filter(type__in=_in)
def is_date_in_range(self, date=None):
"""
Return true if the given date is in the diffusion's start-end
range.
"""
date = date or tz.now()
return self.start < date < self.end
def get_conflicts(self):
""" Return conflicting diffusions queryset """
# conflicts=Diffusion.objects.filter(Q(start__lt=OuterRef('start'), end__gt=OuterRef('end')) | Q(start__gt=OuterRef('start'), start__lt=OuterRef('end')))
# diffs= Diffusion.objects.annotate(conflict_with=Exists(conflicts)).filter(conflict_with=True)
return Diffusion.objects.filter(
Q(start__lt=self.start, end__gt=self.start) |
Q(start__gt=self.start, start__lt=self.end)
).exclude(pk=self.pk).distinct()
def check_conflicts(self):
conflicts = self.get_conflicts()
self.conflicts.set(conflicts)
_initial = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._initial = {
'start': self.start,
'end': self.end,
'episode': getattr(self, 'episode', None),
}

264
aircox/models/log.py Normal file
View File

@ -0,0 +1,264 @@
import datetime
from enum import IntEnum
import logging
import os
from django.db import models
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from aircox import settings, utils
from .episode import Diffusion
from .sound import Sound, Track
from .station import Station
logger = logging.getLogger('aircox')
__all__ = ['Log', 'LogQuerySet']
class LogQuerySet(models.QuerySet):
def station(self, station):
return self.filter(station=station)
def at(self, date=None):
date = utils.date_or_default(date)
return self.filter(date__date=date)
def on_air(self):
return self.filter(type=Log.Type.on_air)
def start(self):
return self.filter(type=Log.Type.start)
def with_diff(self, with_it=True):
return self.filter(diffusion__isnull=not with_it)
def with_sound(self, with_it=True):
return self.filter(sound__isnull=not with_it)
def with_track(self, with_it=True):
return self.filter(track__isnull=not with_it)
@staticmethod
def _get_archive_path(station, date):
# note: station name is not included in order to avoid problems
# of retrieving archive when it changes
return os.path.join(
settings.AIRCOX_LOGS_ARCHIVES_DIR,
'{}_{}.log.gz'.format(date.strftime("%Y%m%d"), station.pk)
)
@staticmethod
def _get_rel_objects(logs, type, attr):
"""
From a list of dict representing logs, retrieve related objects
of the given type.
Example: _get_rel_objects([{..},..], Diffusion, 'diffusion')
"""
attr_id = attr + '_id'
return {
rel.pk: rel
for rel in type.objects.filter(
pk__in=(
log[attr_id]
for log in logs if attr_id in log
)
)
}
def load_archive(self, station, date):
"""
Return archived logs for a specific date as a list
"""
import yaml
import gzip
path = self._get_archive_path(station, date)
if not os.path.exists(path):
return []
with gzip.open(path, 'rb') as archive:
data = archive.read()
logs = yaml.load(data)
# we need to preload diffusions, sounds and tracks
rels = {
'diffusion': self._get_rel_objects(logs, Diffusion, 'diffusion'),
'sound': self._get_rel_objects(logs, Sound, 'sound'),
'track': self._get_rel_objects(logs, Track, 'track'),
}
def rel_obj(log, attr):
attr_id = attr + '_id'
rel_id = log.get(attr + '_id')
return rels[attr][rel_id] if rel_id else None
# make logs
return [
Log(diffusion=rel_obj(log, 'diffusion'),
sound=rel_obj(log, 'sound'),
track=rel_obj(log, 'track'),
**log)
for log in logs
]
def make_archive(self, station, date, force=False, keep=False):
"""
Archive logs of the given date. If the archive exists, it does
not overwrite it except if "force" is given. In this case, the
new elements will be appended to the existing archives.
Return the number of archived logs, -1 if archive could not be
created.
"""
import yaml
import gzip
os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True)
path = self._get_archive_path(station, date)
if os.path.exists(path) and not force:
return -1
qs = self.station(station).at(date)
if not qs.exists():
return 0
fields = Log._meta.get_fields()
logs = [{i.attname: getattr(log, i.attname)
for i in fields} for log in qs]
# Note: since we use Yaml, we can just append new logs when file
# exists yet <3
with gzip.open(path, 'ab') as archive:
data = yaml.dump(logs).encode('utf8')
archive.write(data)
if not keep:
qs.delete()
return len(logs)
class Log(models.Model):
"""
Log sounds and diffusions that are played on the station.
This only remember what has been played on the outputs, not on each
source; Source designate here which source is responsible of that.
"""
class Type(IntEnum):
stop = 0x00
"""
Source has been stopped, e.g. manually
"""
start = 0x01
"""
The diffusion or sound has been triggered by the streamer or
manually.
"""
load = 0x02
"""
A playlist has updated, and loading started. A related Diffusion
does not means that the playlist is only for it (e.g. after a
crash, it can reload previous remaining sound files + thoses of
the next diffusion)
"""
on_air = 0x03
"""
The sound or diffusion has been detected occurring on air. Can
also designate live diffusion, although Liquidsoap did not play
them since they don't have an attached sound archive.
"""
other = 0x04
"""
Other log
"""
type = models.SmallIntegerField(
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
blank=True, null=True,
verbose_name=_('type'),
)
station = models.ForeignKey(
Station, models.CASCADE,
verbose_name=_('station'),
help_text=_('related station'),
)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
# source is removed
max_length=64, blank=True, null=True,
verbose_name=_('source'),
help_text=_('identifier of the source related to this log'),
)
date = models.DateTimeField(
default=tz.now, db_index=True,
verbose_name=_('date'),
)
comment = models.CharField(
max_length=512, blank=True, null=True,
verbose_name=_('comment'),
)
diffusion = models.ForeignKey(
Diffusion, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Diffusion'),
)
sound = models.ForeignKey(
Sound, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Sound'),
)
track = models.ForeignKey(
Track, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Track'),
)
objects = LogQuerySet.as_manager()
@property
def related(self):
return self.diffusion or self.sound or self.track
@property
def local_date(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
return tz.localtime(self.date, tz.get_current_timezone())
def print(self):
r = []
if self.diffusion:
r.append('diff: ' + str(self.diffusion_id))
if self.sound:
r.append('sound: ' + str(self.sound_id))
if self.track:
r.append('track: ' + str(self.track_id))
logger.info('log %s: %s%s', str(self), self.comment or '',
' (' + ', '.join(r) + ')' if r else '')
def __str__(self):
return '#{} ({}, {}, {})'.format(
self.pk, self.get_type_display(),
self.source, self.local_date.strftime('%Y/%m/%d %H:%M%z'))

73
aircox/models/page.py Normal file
View File

@ -0,0 +1,73 @@
from enum import IntEnum
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from ckeditor.fields import RichTextField
from filer.fields.image import FilerImageField
from model_utils.managers import InheritanceQuerySet
__all__ = ['Page', 'PageQuerySet']
class PageQuerySet(InheritanceQuerySet):
def published(self):
return self.filter(status=Page.STATUS.published)
class Page(models.Model):
""" Base class for publishable content """
class STATUS(IntEnum):
draft = 0x00
published = 0x10
trash = 0x20
title = models.CharField(max_length=128)
slug = models.SlugField(_('slug'), blank=True, unique=True)
status = models.PositiveSmallIntegerField(
_('status'),
default=STATUS.draft,
choices=[(int(y), _(x)) for x, y in STATUS.__members__.items()],
)
cover = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Cover'),
)
content = RichTextField(
_('content'), blank=True, null=True,
)
featured = models.BooleanField(
_('featured'), default=False,
)
allow_comments = models.BooleanField(
_('allow comments'), default=True,
)
objects = PageQuerySet.as_manager()
class Meta:
abstract=True
def __str__(self):
return '{}: {}'.format(self._meta.verbose_name,
self.title or self.pk)
def save(self, *args, **kwargs):
# TODO: ensure unique slug
if not self.slug:
self.slug = slugify(self.title)
print(self.title, '--', self.slug)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse(self.detail_url_name, kwargs={'slug': self.slug}) \
if self.is_published else ''
@property
def is_published(self):
return self.status == self.STATUS.published

508
aircox/models/program.py Normal file
View File

@ -0,0 +1,508 @@
import calendar
from collections import OrderedDict
import datetime
from enum import IntEnum
import logging
import os
import shutil
import pytz
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 ugettext_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']
class ProgramQuerySet(PageQuerySet):
def station(self, station):
# FIXME: reverse-lookup
return self.filter(station=station)
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.
"""
station = models.ForeignKey(
Station,
verbose_name=_('station'),
on_delete=models.CASCADE,
)
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()
@property
def path(self):
""" Return program's directory path """
return os.path.join(settings.AIRCOX_PROGRAMS_DIR, self.slug)
@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
@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).
"""
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/':
path = path[1:]
while path[-1] == '/':
path = path[:-2]
if '/' in path:
path = path[:path.index('/')]
path = path.split('_')
path = path[-1]
qs = cl.objects.filter(id=int(path))
return qs[0] if qs else None
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.path, subdir) if subdir else \
self.path
os.makedirs(path, exist_ok=True)
return os.path.exists(path)
def __str__(self):
return self.title
def save(self, *kargs, **kwargs):
from .sound import Sound
super().save(*kargs, **kwargs)
path_ = getattr(self, '__initial_path', None)
if path_ is not None and path_ != self.path and \
os.path.exists(path_) and not os.path.exists(self.path):
logger.info('program #%s\'s dir changed to %s - update it.',
self.id, self.title)
shutil.move(path_, self.path)
Sound.objects.filter(path__startswith=path_) \
.update(path=Concat('path', Substr(F('path'), len(path_))))
class BaseRerunQuerySet(models.QuerySet):
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.
`start` datetime field or property must be implemented by sub-classes
"""
program = models.ForeignKey(
Program, models.CASCADE,
verbose_name=_('related program'),
)
initial = models.ForeignKey(
'self', models.SET_NULL, related_name='rerun_set',
verbose_name=_('initial schedule'),
blank=True, null=True,
help_text=_('mark as rerun of this %(model_name)'),
)
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 initial')
})
# 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}s of the month'),
'second_and_fourth': _('2nd and 4th {day}s 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')
)
# 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 match(self, date=None, check_time=True):
"""
Return True if the given date(time) matches the schedule.
"""
date = utils.date_or_default(
date, tz.datetime if check_time else datetime.date)
if self.date.weekday() != date.weekday() or \
not self.match_week(date):
return False
# we check against a normalized version (norm_date will have
# schedule's date.
return date == self.normalize(date) if check_time else True
def match_week(self, date=None):
"""
Return True if the given week number matches the schedule, False
otherwise.
If the schedule is ponctual, return None.
"""
if self.frequency == Schedule.Frequency.ponctual:
return False
# since we care only about the week, go to the same day of the week
date = utils.date_or_default(date, datetime.date)
date += tz.timedelta(days=self.date.weekday() - date.weekday())
# FIXME this case
if self.frequency == Schedule.Frequency.one_on_two:
# cf notes in date_of_month
diff = date - utils.cast_date(self.date, datetime.date)
return not (diff.days % 14)
first_of_month = date.replace(day=1)
week = date.isocalendar()[1] - first_of_month.isocalendar()[1]
# weeks of month
if week == 4:
# fifth week: return if for every week
return self.frequency == self.Frequency.every
return (self.frequency & (0b0001 << week) > 0)
def normalize(self, date):
"""
Return a new datetime with schedule time. Timezone is handled
using `schedule.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)
.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_date(self.program, date)
episodes[date] = episode
else:
episode = episodes[initial]
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode, 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')
# initial only if it has been yet saved
if self.pk:
self.__initial = self.__dict__.copy()
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')
)

288
aircox/models/sound.py Normal file
View File

@ -0,0 +1,288 @@
from enum import IntEnum
import logging
import os
from django.conf import settings as main_settings
from django.db import models
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import ugettext_lazy as _
from taggit.managers import TaggableManager
from aircox import settings
from .program import Program
from .episode import Episode
logger = logging.getLogger('aircox')
__all__ = ['Sound', 'SoundQuerySet', 'Track']
class SoundQuerySet(models.QuerySet):
def podcasts(self):
""" Return sound available as podcasts """
return self.filter(Q(embed__isnull=False) | Q(is_public=True))
def episode(self, episode):
return self.filter(episode=episode)
def diffusion(self, diffusion):
return self.filter(episode__diffusion=diffusion)
class Sound(models.Model):
"""
A Sound is the representation of a sound file that can be either an excerpt
or a complete archive of the related diffusion.
"""
class Type(IntEnum):
other = 0x00,
archive = 0x01,
excerpt = 0x02,
removed = 0x03,
name = models.CharField(_('name'), max_length=64)
program = models.ForeignKey(
Program, models.SET_NULL, blank=True, null=True,
verbose_name=_('program'),
help_text=_('program related to it'),
)
episode = models.ForeignKey(
Episode, models.SET_NULL, blank=True, null=True,
verbose_name=_('episode'),
)
type = models.SmallIntegerField(
verbose_name=_('type'),
choices=[(int(y), _(x)) for x, y in Type.__members__.items()],
blank=True, null=True
)
# FIXME: url() does not use the same directory than here
# should we use FileField for more reliability?
path = models.FilePathField(
_('file'),
path=settings.AIRCOX_PROGRAMS_DIR,
match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
.replace('.', r'\.') + ')$',
recursive=True, max_length=255,
blank=True, null=True, unique=True,
)
embed = models.TextField(
_('embed'),
blank=True, null=True,
help_text=_('HTML code to embed a sound from an external plateform'),
)
duration = models.TimeField(
_('duration'),
blank=True, null=True,
help_text=_('duration of the sound'),
)
mtime = models.DateTimeField(
_('modification time'),
blank=True, null=True,
help_text=_('last modification date and time'),
)
is_good_quality = models.BooleanField(
_('good quality'), help_text=_('sound meets quality requirements'),
blank=True, null=True
)
is_public = models.BooleanField(
_('public'), help_text=_('if it can be podcasted from the server'),
default=False,
)
objects = SoundQuerySet.as_manager()
def get_mtime(self):
"""
Get the last modification date from file
"""
mtime = os.stat(self.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
# db does not store microseconds
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def url(self):
"""
Return an url to the stream
"""
# path = self._meta.get_field('path').path
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
#path = self.path.replace(path, '', 1)
return main_settings.MEDIA_URL + '/' + path
def file_exists(self):
"""
Return true if the file still exists
"""
return os.path.exists(self.path)
def file_metadata(self):
"""
Get metadata from sound file and return a Track object if succeed,
else None.
"""
if not self.file_exists():
return None
import mutagen
try:
meta = mutagen.File(self.path)
except:
meta = {}
if meta is None:
meta = {}
def get_meta(key, cast=str):
value = meta.get(key)
return cast(value[0]) if value else None
info = '{} ({})'.format(get_meta('album'), get_meta('year')) \
if meta and ('album' and 'year' in meta) else \
get_meta('album') \
if 'album' else \
('year' in meta) and get_meta('year') or ''
return Track(sound=self,
position=get_meta('tracknumber', int) or 0,
title=get_meta('title') or self.name,
artist=get_meta('artist') or _('unknown'),
info=info)
def check_on_file(self):
"""
Check sound file info again'st self, and update informations if
needed (do not save). Return True if there was changes.
"""
if not self.file_exists():
if self.type == self.Type.removed:
return
logger.info('sound %s: has been removed', self.path)
self.type = self.Type.removed
return True
# not anymore removed
changed = False
if self.type == self.Type.removed and self.program:
changed = True
self.type = self.Type.archive \
if self.path.startswith(self.program.archives_path) else \
self.Type.excerpt
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()
if self.mtime != mtime:
self.mtime = mtime
self.is_good_quality = None
logger.info('sound %s: m_time has changed. Reset quality info',
self.path)
return True
return changed
def check_perms(self):
"""
Check file permissions and update it if the sound is public
"""
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
self.removed or not os.path.exists(self.path):
return
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.is_public]
try:
os.chmod(self.path, flags)
except PermissionError as err:
logger.error('cannot set permissions {} to file {}: {}'.format(
self.flags[self.is_public], self.path, err))
def __check_name(self):
if not self.name and self.path:
# FIXME: later, remove date?
self.name = os.path.basename(self.path)
self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__check_name()
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
self.program = self.episode.program
if check:
self.check_on_file()
self.__check_name()
super().save(*args, **kwargs)
def __str__(self):
return '/'.join(self.path.split('/')[-3:])
class Meta:
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
class Track(models.Model):
"""
Track of a playlist of an object. The position can either be expressed
as the position in the playlist or as the moment in seconds it started.
"""
episode = models.ForeignKey(
Episode, models.CASCADE, blank=True, null=True,
verbose_name=_('episode'),
)
sound = models.ForeignKey(
Sound, models.CASCADE, blank=True, null=True,
verbose_name=_('sound'),
)
position = models.PositiveSmallIntegerField(
_('order'),
default=0,
help_text=_('position in the playlist'),
)
timestamp = models.PositiveSmallIntegerField(
_('timestamp'),
blank=True, null=True,
help_text=_('position in seconds')
)
title = models.CharField(_('title'), max_length=128)
artist = models.CharField(_('artist'), max_length=128)
tags = TaggableManager(verbose_name=_('tags'), blank=True,)
info = models.CharField(
_('information'),
max_length=128,
blank=True, null=True,
help_text=_('additional informations about this track, such as '
'the version, if is it a remix, features, etc.'),
)
class Meta:
verbose_name = _('Track')
verbose_name_plural = _('Tracks')
ordering = ('position',)
def __str__(self):
return '{self.artist} -- {self.title} -- {self.position}'.format(
self=self)
def save(self, *args, **kwargs):
if (self.sound is None and self.episode is None) or \
(self.sound is not None and self.episode is not None):
raise ValueError('sound XOR episode is required')
super().save(*args, **kwargs)

206
aircox/models/station.py Normal file
View File

@ -0,0 +1,206 @@
from enum import IntEnum
import os
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
import aircox.settings as settings
__all__ = ['Station', 'StationQuerySet', 'Port']
class StationQuerySet(models.QuerySet):
def default(self, station=None):
"""
Return station model instance, using defaults or
given one.
"""
if station is None:
return self.order_by('-default', 'pk').first()
return self.filter(pk=station).first()
class Station(models.Model):
"""
Represents a radio station, to which multiple programs are attached
and that is used as the top object for everything.
A Station holds controllers for the audio stream generation too.
Theses are set up when needed (at the first access to these elements)
then cached.
"""
name = models.CharField(_('name'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, unique=True)
path = models.CharField(
_('path'),
help_text=_('path to the working directory'),
max_length=256,
blank=True,
)
default = models.BooleanField(
_('default station'),
default=True,
help_text=_('if checked, this station is used as the main one')
)
objects = StationQuerySet.as_manager()
#
# Controllers
#
__sources = None
__dealer = None
__streamer = None
def __prepare_controls(self):
import aircox.controllers as controllers
from .program import Program
if not self.__streamer:
self.__streamer = controllers.Streamer(station=self)
self.__dealer = controllers.Source(station=self)
self.__sources = [self.__dealer] + [
controllers.Source(station=self, program=program)
for program in Program.objects.filter(stream__isnull=False)
]
@property
def inputs(self):
"""
Return all active input ports of the station
"""
return self.port_set.filter(
direction=Port.Direction.input,
active=True
)
@property
def outputs(self):
""" Return all active output ports of the station """
return self.port_set.filter(
direction=Port.Direction.output,
active=True,
)
@property
def sources(self):
""" Audio sources, dealer included """
self.__prepare_controls()
return self.__sources
@property
def dealer(self):
""" Get dealer control """
self.__prepare_controls()
return self.__dealer
@property
def streamer(self):
""" Audio controller for the station """
self.__prepare_controls()
return self.__streamer
def __str__(self):
return self.name
def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(
settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug
)
if self.default:
qs = Station.objects.filter(default=True)
if self.pk:
qs = qs.exclude(pk=self.pk)
qs.update(default=False)
super().save(*args, **kwargs)
class Port (models.Model):
"""
Represent an audio input/output for the audio stream
generation.
You might want to take a look to LiquidSoap's documentation
for the options available for each kind of input/output.
Some port types may be not available depending on the
direction of the port.
"""
class Direction(IntEnum):
input = 0x00
output = 0x01
class Type(IntEnum):
jack = 0x00
alsa = 0x01
pulseaudio = 0x02
icecast = 0x03
http = 0x04
https = 0x05
file = 0x06
station = models.ForeignKey(
Station,
verbose_name=_('station'),
on_delete=models.CASCADE,
)
direction = models.SmallIntegerField(
_('direction'),
choices=[(int(y), _(x)) for x, y in Direction.__members__.items()],
)
type = models.SmallIntegerField(
_('type'),
# we don't translate the names since it is project names.
choices=[(int(y), x) for x, y in Type.__members__.items()],
)
active = models.BooleanField(
_('active'),
default=True,
help_text=_('this port is active')
)
settings = models.TextField(
_('port settings'),
help_text=_('list of comma separated params available; '
'this is put in the output config file as raw code; '
'plugin related'),
blank=True, null=True
)
def is_valid_type(self):
"""
Return True if the type is available for the given direction.
"""
if self.direction == self.Direction.input:
return self.type not in (
self.Type.icecast, self.Type.file
)
return self.type not in (
self.Type.http, self.Type.https
)
def save(self, *args, **kwargs):
if not self.is_valid_type():
raise ValueError(
"port type is not allowed with the given port direction"
)
return super().save(*args, **kwargs)
def __str__(self):
return "{direction}: {type} #{id}".format(
direction=self.get_direction_display(),
type=self.get_type_display(),
id=self.pk or ''
)

View File

@ -33,6 +33,15 @@ ensure('AIRCOX_PROGRAMS_DIR',
ensure('AIRCOX_DATA_DIR',
os.path.join(settings.PROJECT_ROOT, 'data'))
########################################################################
# Programs & Episodes
########################################################################
# default title for episodes
ensure('AIRCOX_EPISODE_TITLE', '{program.title} - {date}')
# date format in episode title (python's strftime)
ensure('AIRCOX_EPISODE_TITLE_DATE_FORMAT', '%-d %B %Y')
########################################################################
# Logs & Archives
########################################################################