create aircox_streamer as separate application

This commit is contained in:
bkfox 2019-09-19 15:22:56 +02:00
parent e30d1b54ef
commit 4e61ec1520
45 changed files with 497 additions and 11934 deletions

View File

@ -1,8 +0,0 @@
from .article import ArticleAdmin
from .episode import DiffusionAdmin, EpisodeAdmin
from .log import LogAdmin
from .program import ProgramAdmin, ScheduleAdmin, StreamAdmin
from .sound import SoundAdmin, TrackAdmin
from .station import StationAdmin

View File

@ -1,18 +0,0 @@
import copy
from django.contrib import admin
from ..models import Article
from .page import PageAdmin
__all__ = ['ArticleAdmin']
@admin.register(Article)
class ArticleAdmin(PageAdmin):
list_filter = PageAdmin.list_filter
search_fields = PageAdmin.search_fields + ['parent__title']
# TODO: readonly field

View File

@ -1,58 +0,0 @@
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 .sound import SoundInline, 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']
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')
@admin.register(Episode)
class EpisodeAdmin(PageAdmin):
list_display = PageAdmin.list_display
list_filter = PageAdmin.list_filter
search_fields = PageAdmin.search_fields + ['parent__title']
# readonly_fields = ('parent',)
inlines = [TracksInline, SoundInline, DiffusionInline]

View File

@ -1,13 +0,0 @@
from django.contrib import admin
from ..models import Log
__all__ = ['LogAdmin']
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ['id', 'date', 'station', 'source', 'type', 'comment']
list_filter = ['date', 'source', 'station']

View File

@ -1,42 +0,0 @@
class UnrelatedInlineMixin:
"""
Inline class that can be included in an admin change view whose model
is not directly related to inline's model.
"""
view_model = None
parent_model = None
parent_fk = ''
def __init__(self, parent_model, admin_site):
self.view_model = parent_model
super().__init__(self.parent_model, admin_site)
def get_parent(self, view_obj):
""" Get formset's instance from `obj` of AdminSite's change form. """
field = self.parent_model._meta.get_field(self.parent_fk).remote_field
return getattr(view_obj, field.name, None)
def save_parent(self, parent, view_obj):
""" Save formset's instance. """
setattr(parent, self.parent_fk, view_obj)
parent.save()
return parent
def get_formset(self, request, obj):
ParentFormSet = super().get_formset(request, obj)
inline = self
class FormSet(ParentFormSet):
view_obj = None
def __init__(self, *args, instance=None, **kwargs):
self.view_obj = instance
instance = inline.get_parent(instance)
self.instance = instance
super().__init__(*args, instance=instance, **kwargs)
def save(self):
inline.save_parent(self.instance, self.view_obj)
return super().save()
return FormSet

View File

@ -1,52 +0,0 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from adminsortable2.admin import SortableInlineAdminMixin
from ..models import Category, Article, NavItem
__all__ = ['CategoryAdmin', 'PageAdmin', 'NavItemInline']
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['pk', 'title', 'slug']
list_editable = ['title', 'slug']
search_fields = ['title']
fields = ['title', 'slug']
prepopulated_fields = {"slug": ("title",)}
# limit category choice
class PageAdmin(admin.ModelAdmin):
list_display = ('cover_thumb', 'title', 'status', 'category', 'parent')
list_display_links = ('cover_thumb', 'title')
list_editable = ('status', 'category')
list_filter = ('status', 'category')
prepopulated_fields = {"slug": ("title",)}
search_fields = ['title', 'category__title']
fieldsets = [
('', {
'fields': ['title', 'slug', 'category', 'cover', 'content'],
}),
(_('Publication Settings'), {
'fields': ['featured', 'allow_comments', 'status', 'parent'],
'classes': ('collapse',),
}),
]
change_form_template = 'admin/aircox/page_change_form.html'
def cover_thumb(self, obj):
return mark_safe('<img src="{}"/>'.format(obj.cover.icons['64'])) \
if obj.cover else ''
class NavItemInline(SortableInlineAdminMixin, admin.TabularInline):
model = NavItem

View File

@ -1,76 +0,0 @@
from copy import deepcopy
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from ..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', 'active')
list_filter = PageAdmin.list_filter + ('station', 'active')
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

@ -1,65 +0,0 @@
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
from adminsortable2.admin import SortableInlineAdminMixin
from ..models import Sound, Track
class TracksInline(SortableInlineAdminMixin, admin.TabularInline):
template = 'admin/aircox/playlist_inline.html'
model = Track
extra = 0
fields = ('position', 'artist', 'title', 'info', 'timestamp', 'tags')
list_display = ['artist', 'title', 'tags', 'related']
list_filter = ['artist', 'title', 'tags']
class SoundInline(admin.TabularInline):
model = Sound
fields = ['type', 'path', 'embed', 'duration', 'is_public']
readonly_fields = ['type', 'path', 'duration']
extra = 0
@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',
'is_public', 'is_good_quality', 'episode', 'filename']
list_filter = ('program', 'type', 'is_good_quality', 'is_public')
search_fields = ['name', 'program']
fieldsets = [
(None, {'fields': ['name', 'path', 'type', 'program', 'episode']}),
(None, {'fields': ['embed', 'duration', 'is_public', 'mtime']}),
(None, {'fields': ['is_good_quality']})
]
readonly_fields = ('path', 'duration',)
inlines = [TracksInline]
@admin.register(Track)
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', 'episode', 'sound', 'timestamp']
list_editable = ['artist', 'title']
list_filter = ['artist', 'title', 'tags']
search_fields = ['artist', 'title']
fieldsets = [
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
# TODO on edit: readonly_fields = ['episode', 'sound']

View File

@ -1,20 +0,0 @@
from django.contrib import admin
from ..models import Port, Station
from .page import NavItemInline
__all__ = ['PortInline', 'StationAdmin']
class PortInline(admin.StackedInline):
model = Port
extra = 0
@admin.register(Station)
class StationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
inlines = [PortInline, NavItemInline]

View File

@ -8,7 +8,6 @@ class AircoxConfig(AppConfig):
class AircoxAdminConfig(AdminConfig):
default_site = 'aircox.views.admin.AdminSite'
default_site = 'aircox.admin_site.AdminSite'

View File

@ -4,7 +4,7 @@ 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
from .station import Station
from . import signals

View File

@ -44,8 +44,8 @@ def program_post_save(sender, instance, created, *args, **kwargs):
Clean-up later diffusions when a program becomes inactive
"""
if not instance.active:
Diffusion.objects.program(instance).after(tz.now()).delete()
Episode.object.program(instance).filter(diffusion__isnull=True) \
Diffusion.object.program(instance).after(tz.now()).delete()
Episode.object.parent(instance).filter(diffusion__isnull=True) \
.delete()
@ -94,7 +94,6 @@ def schedule_pre_delete(sender, instance, *args, **kwargs):
@receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True,
sound__isnull=True) \
.delete()
sound__isnull=True).delete()

View File

@ -1,7 +1,6 @@
import os
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from filer.fields.image import FilerImageField
@ -9,7 +8,7 @@ from filer.fields.image import FilerImageField
from .. import settings
__all__ = ['Station', 'StationQuerySet', 'Port']
__all__ = ['Station', 'StationQuerySet']
class StationQuerySet(models.QuerySet):
@ -22,6 +21,9 @@ class StationQuerySet(models.QuerySet):
return self.order_by('-default', 'pk').first()
return self.filter(pk=station).first()
def active(self):
return self.filter(active=True)
class Station(models.Model):
"""
@ -44,7 +46,12 @@ class Station(models.Model):
default = models.BooleanField(
_('default station'),
default=True,
help_text=_('if checked, this station is used as the main one')
help_text=_('use this station as the main one.')
)
active = models.BooleanField(
_('active'),
default=True,
help_text=_('whether this station is still active or not.')
)
logo = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True,
@ -79,6 +86,20 @@ class Station(models.Model):
super().save(*args, **kwargs)
class PortQuerySet(models.QuerySet):
def active(self, value=True):
""" Active ports """
return self.filter(active=value)
def output(self):
""" Filter in output ports """
return self.filter(direction=Port.DIRECTION_OUTPUT)
def input(self):
""" Fitler in input ports """
return self.filter(direction=Port.DIRECTION_INPUT)
class Port(models.Model):
"""
Represent an audio input/output for the audio stream
@ -126,6 +147,14 @@ class Port(models.Model):
blank=True, null=True
)
objects = PortQuerySet.as_manager()
def __str__(self):
return "{direction}: {type} #{id}".format(
direction=self.get_direction_display(),
type=self.get_type_display(), id=self.pk or ''
)
def is_valid_type(self):
"""
Return True if the type is available for the given direction.
@ -148,11 +177,3 @@ class Port(models.Model):
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

@ -1,3 +1,4 @@
# Code inspired from rest_framework of course.
import os
import stat

View File

@ -7221,7 +7221,7 @@ a.navbar-item.is-active {
.card-super-title {
position: absolute;
z-index: 1000;
font-size: 1.2em;
font-size: 1rem;
font-weight: 700;
padding: 0.2em;
top: 1em;

View File

@ -234,7 +234,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var buefy_dist_buefy_css__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! buefy/dist/buefy.css */ \"./node_modules/buefy/dist/buefy.css\");\n/* harmony import */ var buefy_dist_buefy_css__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(buefy_dist_buefy_css__WEBPACK_IMPORTED_MODULE_3__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _liveInfo__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./liveInfo */ \"./assets/public/liveInfo.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_6__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_7__[\"default\"])\n\n\nwindow.aircox = {\n app: _app__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n LiveInfo: _liveInfo__WEBPACK_IMPORTED_MODULE_5__[\"default\"],\n}\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?");
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _liveInfo__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./liveInfo */ \"./assets/public/liveInfo.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_5__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_6__[\"default\"])\n\n\nwindow.aircox = {\n app: _app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n LiveInfo: _liveInfo__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n}\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?");
/***/ }),

View File

@ -7203,7 +7203,7 @@ a.navbar-item.is-active {
.card-super-title {
position: absolute;
z-index: 1000;
font-size: 1.2em;
font-size: 1rem;
font-weight: 700;
padding: 0.2em;
top: 1em;

View File

@ -175,7 +175,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var buefy_dist_buefy_css__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! buefy/dist/buefy.css */ \"./node_modules/buefy/dist/buefy.css\");\n/* harmony import */ var buefy_dist_buefy_css__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(buefy_dist_buefy_css__WEBPACK_IMPORTED_MODULE_3__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _liveInfo__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./liveInfo */ \"./assets/public/liveInfo.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_6__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_7__[\"default\"])\n\n\nwindow.aircox = {\n app: _app__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n LiveInfo: _liveInfo__WEBPACK_IMPORTED_MODULE_5__[\"default\"],\n}\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?");
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var vue__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! vue */ \"./node_modules/vue/dist/vue.esm.browser.js\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/all.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_all_min_css__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! @fortawesome/fontawesome-free/css/fontawesome.min.css */ \"./node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css\");\n/* harmony import */ var _fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_fortawesome_fontawesome_free_css_fontawesome_min_css__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./assets/public/app.js\");\n/* harmony import */ var _liveInfo__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./liveInfo */ \"./assets/public/liveInfo.js\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./styles.scss */ \"./assets/public/styles.scss\");\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_styles_scss__WEBPACK_IMPORTED_MODULE_5__);\n/* harmony import */ var _player_vue__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./player.vue */ \"./assets/public/player.vue\");\n/**\n * This module includes code available for both the public website and\n * administration interface)\n */\n//-- vendor\n\n\n\n\n\n\n//-- aircox\n\n\n\n\n\n\n\nvue__WEBPACK_IMPORTED_MODULE_0__[\"default\"].component('a-player', _player_vue__WEBPACK_IMPORTED_MODULE_6__[\"default\"])\n\n\nwindow.aircox = {\n app: _app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n LiveInfo: _liveInfo__WEBPACK_IMPORTED_MODULE_4__[\"default\"],\n}\n\n\n\n//# sourceURL=webpack:///./assets/public/index.js?");
/***/ }),

File diff suppressed because it is too large Load Diff

View File

@ -22,17 +22,6 @@ eval("// extracted by mini-css-extract-plugin\n\n//# sourceURL=webpack:///./node
/***/ }),
/***/ "./node_modules/buefy/dist/buefy.css":
/*!*******************************************!*\
!*** ./node_modules/buefy/dist/buefy.css ***!
\*******************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {
eval("// extracted by mini-css-extract-plugin\n\n//# sourceURL=webpack:///./node_modules/buefy/dist/buefy.css?");
/***/ }),
/***/ "./node_modules/process/browser.js":
/*!*****************************************!*\
!*** ./node_modules/process/browser.js ***!

View File

@ -2,16 +2,9 @@
{% comment %}
Context:
-
-
TODO:
- sidebar:
- logs
- diffusions
- main:
- focused
- nav to 'publications' view
-
{% endcomment %}
{% load i18n %}
@ -24,11 +17,17 @@ TODO:
{% for object in top_diffs %}
{% with is_primary=object.is_now %}
<div class="column is-relative">
<h4 class="card-super-title">
<h4 class="card-super-title" title="{{ object.start }}">
{% if is_primary %}
<span class="fas fa-play"></span>
{% trans "Currently" %}
{% else %}{{ object.start|date:"H:i" }}{% endif %}
<time datetime="{{ object.start }}">
{% trans "Currently" %}
{% else %}{{ object.start|date:"H:i" }}{% endif %}
</time>
{% if object.episode.category %}
// {{ object.episode.category.title }}
{% endif %}
</h4>
{% include object.item_template_name %}
</div>

View File

@ -1,4 +1,3 @@
from django.contrib import admin
from django.urls import include, path, register_converter
from django.utils.translation import ugettext_lazy as _

View File

@ -1,18 +1,14 @@
"""
Aircox admin tools and views.
"""
import datetime
from django.contrib import admin
from django.contrib.auth.mixins import LoginRequiredMixin, \
PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.utils.translation import ugettext_lazy as _
from django.views.generic import ListView
from ..models import Program
from .log import LogListView
__all__ = ['BaseAdminView', 'StatisticsView']
class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin):
title = ''
@ -27,6 +23,7 @@ class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin):
class StatisticsView(BaseAdminView, LogListView, ListView):
template_name = 'admin/aircox/statistics.html'
redirect_date_url = 'tools-stats'
title = _('Statistics')
date = None
@ -34,26 +31,3 @@ class StatisticsView(BaseAdminView, LogListView, ListView):
return super().get_object_list(logs, True)
class AdminSite(admin.AdminSite):
def each_context(self, request):
context = super().each_context(request)
context.update({
'programs': Program.objects.all().active().values('pk', 'title'),
})
return context
def get_urls(self):
from django.urls import path, include
urls = super().get_urls() + [
path('tools/statistics/',
self.admin_view(StatisticsView.as_view()),
name='tools-stats'),
path('tools/statistics/<date:date>/',
self.admin_view(StatisticsView.as_view()),
name='tools-stats'),
]
return urls
admin_site = AdminSite()

View File

@ -3,6 +3,8 @@ import datetime
from django.utils import timezone as tz
from rest_framework.generics import ListAPIView
from rest_framework import viewsets
from rest_framework.decorators import action
from ..models import Log
from ..serializers import LogInfo, LogInfoSerializer
@ -44,4 +46,3 @@ class LogListAPIView(LogListMixin, BaseAPIView, ListAPIView):
return super().get_serializer(self.get_object_list(queryset, full),
*args, **kwargs)

View File

@ -51,10 +51,11 @@ class BaseView(TemplateResponseMixin, ContextMixin):
kwargs['sidebar_object_list'] = sidebar_object_list[:self.list_count]
kwargs['sidebar_list_url'] = self.get_sidebar_url()
if not 'audio_streams' in kwargs:
if 'audio_streams' not in kwargs:
streams = self.station.audio_streams
streams = streams and streams.split('\n')
kwargs['audio_streams'] = streams
return super().get_context_data(**kwargs)

View File

@ -14,7 +14,6 @@ from .base import BaseView
__all__ = ['PageDetailView', 'PageListView']
# TODO: pagination: in template, only a limited number of pages displayed
class PageListView(BaseView, ListView):
template_name = 'aircox/page_list.html'
item_template_name = 'aircox/widgets/page_item.html'

View File

17
aircox_streamer/admin.py Normal file
View File

@ -0,0 +1,17 @@
from django.contrib import admin
from aircox.admin import StationAdmin
from .models import Port
__all__ = ['PortInline']
class PortInline(admin.StackedInline):
model = Port
extra = 0
StationAdmin.inlines = (PortInline,) + StationAdmin.inlines

5
aircox_streamer/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AircoxStreamerConfig(AppConfig):
name = 'aircox_streamer'

View File

@ -11,11 +11,19 @@ import tzlocal
from django.template.loader import render_to_string
from django.utils import timezone as tz
from . import settings
from .models import Port, Station, Sound
from .connector import Connector
from .utils import to_seconds
from aircox import settings
from aircox.models import Station, Sound
from aircox.utils import to_seconds
from .connector import Connector
from .models import Port
__all__ = ['BaseMetadata', 'Request', 'Streamer', 'Source',
'PlaylistSource', 'QueueSource']
# TODO: for the moment, update in station and program names do not update the
# related fields.
# FIXME liquidsoap does not manage timezones -- we have to convert
# 'on_air' metadata we get from it into utc one in order to work
@ -25,12 +33,64 @@ local_tz = tzlocal.get_localzone()
logger = logging.getLogger('aircox')
class BaseMetadata:
""" Base class for handling request metadata. """
controller = None
""" Controller """
rid = None
""" Request id """
uri = None
""" Request uri """
status = None
""" Current playing status """
air_time = None
""" Launch datetime """
def __init__(self, controller=None, rid=None, data=None):
self.controller = controller
self.rid = rid
if data is not None:
self.validate(data)
@property
def is_playing(self):
return self.status == 'playing'
def fetch(self):
data = self.controller.set('request.metadata ', self.rid, parse=True)
if data:
self.validate(data)
def validate(self, data):
"""
Validate provided data and set as attribute (must already be
declared)
"""
for key, value in data.items():
if hasattr(self, key) and not callable(getattr(self, key)):
setattr(self, key, value)
self.uri = data.get('initial_uri')
air_time = data.get('on_air')
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
self.air_time = local_tz.localize(air_time)
else:
self.air_time = None
class Request(BaseMetadata):
title = None
artist = None
class Streamer:
connector = None
process = None
station = None
template_name = 'aircox/scripts/station.liq'
template_name = 'aircox_streamer/scripts/station.liq'
path = None
""" Config path """
sources = None
@ -41,9 +101,16 @@ class Streamer:
# moment
# on_air = None
# """ On-air request ids (rid) """
inputs = None
""" Queryset to input ports """
outputs = None
""" Queryset to output ports """
def __init__(self, station):
def __init__(self, station, connector=None):
self.station = station
self.inputs = self.station.port_set.active().input()
self.outputs = self.station.port_set.active().output()
self.id = self.station.slug.replace('-', '_')
self.path = os.path.join(station.path, 'station.liq')
self.connector = Connector(os.path.join(station.path, 'station.sock'))
@ -63,6 +130,7 @@ class Streamer:
@property
def is_running(self):
""" True if holds a running process """
if self.process is None:
return False
@ -74,19 +142,6 @@ class Streamer:
logger.debug('process died with return code %s' % returncode)
return False
# FIXME: is it really needed as property?
@property
def inputs(self):
""" Return input ports of the station """
return self.station.port_set.filter(direction=Port.DIRECTION_INPUT,
active=True)
@property
def outputs(self):
""" Return output ports of the station """
return self.station.port_set.filter(direction=Port.DIRECTION_OUTPUT,
active=True)
# Sources and config ###############################################
def send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) or ''
@ -121,9 +176,6 @@ class Streamer:
def fetch(self):
""" Fetch data from liquidsoap """
if self.process is None:
return
for source in self.sources:
source.fetch()
@ -182,19 +234,11 @@ class Streamer:
self.process = None
class Source:
class Source(BaseMetadata):
controller = None
""" parent controller """
id = None
""" source id """
uri = ''
""" source uri """
rid = None
""" request id """
air_time = None
""" on air time """
status = None
""" source status """
remaining = 0.0
""" remaining time """
@ -202,16 +246,12 @@ class Source:
def station(self):
return self.controller.station
@property
def is_playing(self):
return self.status == 'playing'
# @property
# def is_on_air(self):
# return self.rid is not None and self.rid in self.controller.on_air
def __init__(self, controller, id=None):
self.controller = controller
def __init__(self, controller=None, id=None, *args, **kwargs):
super().__init__(controller, *args, **kwargs)
self.id = id
def sync(self):
@ -219,23 +259,12 @@ class Source:
def fetch(self):
data = self.controller.send(self.id, '.remaining')
self.remaining = float(data)
if data:
self.remaining = float(data)
data = self.controller.send(self.id, '.get', parse=True)
self.on_metadata(data if data and isinstance(data, dict) else {})
def on_metadata(self, data):
""" Update source info from provided request metadata """
self.rid = data.get('rid') or None
self.uri = data.get('initial_uri') or None
self.status = data.get('status') or None
air_time = data.get('on_air')
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
self.air_time = local_tz.localize(air_time)
else:
self.air_time = None
if data:
self.validate(data if data and isinstance(data, dict) else {})
def skip(self):
""" Skip the current source sound """
@ -271,15 +300,15 @@ class PlaylistSource(Source):
""" Get playlist's sounds queryset """
return self.program.sound_set.archive()
def load_playlist(self):
""" Load playlist """
self.playlist = self.get_sound_queryset().paths()
def get_playlist(self):
""" Get playlist from db """
return self.get_sound_queryset().paths()
def write_playlist(self):
""" Write playlist file. """
def write_playlist(self, playlist=[]):
""" Write playlist to file. """
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, 'w') as file:
file.write('\n'.join(self.playlist or []))
file.write('\n'.join(playlist or []))
def stream(self):
""" Return program's stream info if any (or None) as dict. """
@ -296,15 +325,21 @@ class PlaylistSource(Source):
}
def sync(self):
self.load_playlist()
self.write_playlist()
playlist = self.get_playlist()
self.write_playlist(playlist)
class QueueSource(Source):
queue = None
""" Source's queue (excluded on_air request) """
as_requests = False
""" If True, queue is a list of Request """
def append(self, *paths):
def __init__(self, *args, queue_metadata=False, **kwargs):
super().__init__(*args, **kwargs)
self.queue_metadata = queue_metadata
def push(self, *paths):
""" Add the provided paths to source's play queue """
for path in paths:
self.controller.send(self.id, '_queue.push ', path)
@ -312,4 +347,12 @@ class QueueSource(Source):
def fetch(self):
super().fetch()
queue = self.controller.send(self.id, '_queue.queue').split(' ')
self.queue = queue
if not self.as_requests:
self.queue = queue
return
self.queue = [Request(self.controller, rid) for rid in queue]
for request in self.queue:
request.fetch()

View File

View File

@ -21,10 +21,11 @@ from django.db.models import Q
from django.core.management.base import BaseCommand
from django.utils import timezone as tz
from aircox.controllers import Streamer, PlaylistSource
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.utils import date_range
from aircox_streamer.liquidsoap import Streamer, PlaylistSource
# force using UTC
tz.activate(pytz.UTC)
@ -227,7 +228,7 @@ class Monitor:
def start_diff(self, source, diff):
playlist = Sound.objects.episode(id=diff.episode_id).paths()
source.append(*playlist)
source.push(*playlist)
self.log(type=Log.TYPE_START, source=source.id, diffusion=diff,
comment=str(diff))

101
aircox_streamer/models.py Normal file
View File

@ -0,0 +1,101 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from aircox.models import Station
__all__ = ['PortQuerySet', 'Port']
class PortQuerySet(models.QuerySet):
def active(self, value=True):
""" Active ports """
return self.filter(active=value)
def output(self):
""" Filter in output ports """
return self.filter(direction=Port.DIRECTION_OUTPUT)
def input(self):
""" Fitler in input ports """
return self.filter(direction=Port.DIRECTION_INPUT)
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.
"""
DIRECTION_INPUT = 0x00
DIRECTION_OUTPUT = 0x01
DIRECTION_CHOICES = ((DIRECTION_INPUT, _('input')),
(DIRECTION_OUTPUT, _('output')))
TYPE_JACK = 0x00
TYPE_ALSA = 0x01
TYPE_PULSEAUDIO = 0x02
TYPE_ICECAST = 0x03
TYPE_HTTP = 0x04
TYPE_HTTPS = 0x05
TYPE_FILE = 0x06
TYPE_CHOICES = (
# display value are not translated becaused used as is in config
(TYPE_JACK, 'jack'), (TYPE_ALSA, 'alsa'),
(TYPE_PULSEAUDIO, 'pulseaudio'), (TYPE_ICECAST, 'icecast'),
(TYPE_HTTP, 'http'), (TYPE_HTTPS, 'https'),
(TYPE_FILE, 'file')
)
station = models.ForeignKey(
Station, models.CASCADE, verbose_name=_('station'), related_name='+')
direction = models.SmallIntegerField(
_('direction'), choices=DIRECTION_CHOICES)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
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
)
objects = PortQuerySet.as_manager()
def __str__(self):
return "{direction}: {type} #{id}".format(
direction=self.get_direction_display(),
type=self.get_type_display(), id=self.pk or ''
)
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)

View File

@ -0,0 +1,40 @@
from rest_framework import serializers
__all__ = ['RequestSerializer', 'StreamerSerializer', 'SourceSerializer',
'PlaylistSerializer', 'QueueSourceSerializer']
# TODO: use models' serializers
class BaseMetadataSerializer(serializers.Serializer):
rid = serializers.IntegerField()
air_time = serializers.DateTimeField()
uri = serializers.CharField()
class RequestSerializer(serializers.Serializer):
title = serializers.CharField()
artist = serializers.CharField()
class StreamerSerializer(serializers.Serializer):
station = serializers.CharField(source='station.title')
class SourceSerializer(BaseMetadataSerializer):
id = serializers.CharField()
uri = serializers.CharField()
rid = serializers.IntegerField()
air_time = serializers.DateTimeField()
status = serializers.CharField()
class PlaylistSerializer(SourceSerializer):
program = serializers.CharField(source='program.title')
playlist = serializers.ListField(child=serializers.CharField())
class QueueSourceSerializer(SourceSerializer):
queue = serializers.ListField(child=RequestSerializer())

3
aircox_streamer/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
aircox_streamer/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

158
aircox_streamer/viewsets.py Normal file
View File

@ -0,0 +1,158 @@
from django.http import Http404
from django.utils import timezone as tz
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAdminUser
from aircox import controllers
from aircox.models import Station
from .serializers import *
__all__ = ['Streamers', 'BaseControllerAPIView',
'RequestViewSet', 'StreamerViewSet', 'SourceViewSet',
'PlaylistSourceViewSet', 'QueueSourceViewSet']
class Streamers:
date = None
""" next update datetime """
streamers = None
""" stations by station id """
timeout = None
""" timedelta to next update """
def __init__(self, timeout=None):
self.timeout = timeout or tz.timedelta(seconds=2)
def load(self, force=False):
# FIXME: cf. TODO in aircox.controllers about model updates
stations = Station.objects.active()
if self.streamers is None or force:
self.streamers = {station.pk: controllers.Streamer(station)
for station in stations}
return
streamers = self.streamers
self.streamers = {station.pk: controllers.Streamer(station)
if station.pk in streamers else streamers[station.pk]
for station in stations}
def fetch(self):
if self.streamers is None:
self.load()
now = tz.now()
if self.date is not None and now < self.date:
return
for streamer in self.streamers.values():
streamer.fetch()
self.date = now + self.timeout
def get(self, key, default=None):
self.fetch()
return self.streamers.get(key, default)
def values(self):
self.fetch()
return self.streamers.values()
def __getitem__(self, key):
return self.streamers[key]
streamers = Streamers()
class BaseControllerAPIView(viewsets.ViewSet):
permission_classes = (IsAdminUser,)
serializer = None
streamer = None
def get_streamer(self, pk=None):
streamer = streamers.get(self.request.pk if pk is None else pk)
if not streamer:
raise Http404('station not found')
return streamer
def get_serializer(self, obj, **kwargs):
return self.serializer(obj, **kwargs)
def serialize(self, obj, **kwargs):
serializer = self.get_serializer(obj, **kwargs)
return serializer.data
def dispatch(self, request, *args, **kwargs):
self.streamer = self.get_streamer(request.station.pk)
return super().dispatch(request, *args, **kwargs)
class RequestViewSet(BaseControllerAPIView):
serializer = RequestSerializer
class StreamerViewSet(BaseControllerAPIView):
serializer = StreamerSerializer
def retrieve(self, request, pk=None):
return self.serialize(self.streamer)
def list(self, request):
return self.serialize(streamers.values(), many=True)
class SourceViewSet(BaseControllerAPIView):
serializer = SourceSerializer
model = controllers.Source
def get_sources(self):
return (s for s in self.streamer.souces if isinstance(s, self.model))
def get_source(self, pk):
source = next((source for source in self.get_sources()
if source.pk == pk), None)
if source is None:
raise Http404('source `%s` not found' % pk)
return source
def retrieve(self, request, pk=None):
source = self.get_source(pk)
return self.serialize(source)
def list(self, request):
return self.serialize(self.get_sources(), many=True)
@action(detail=True, methods=['POST'])
def sync(self, request, pk):
self.get_source(pk).sync()
@action(detail=True, methods=['POST'])
def skip(self, request, pk):
self.get_source(pk).skip()
@action(detail=True, methods=['POST'])
def restart(self, request, pk):
self.get_source(pk).restart()
@action(detail=True, methods=['POST'])
def seek(self, request, pk):
count = request.POST['seek']
self.get_source(pk).seek(count)
class PlaylistSourceViewSet(SourceViewSet):
serializer = PlaylistSerializer
model = controllers.PlaylistSource
class QueueSourceViewSet(SourceViewSet):
serializer = QueueSourceSerializer
model = controllers.QueueSource
@action(detail=True, methods=['POST'])
def push(self, request, pk):
self.get_source(pk).push()

View File

@ -7,7 +7,6 @@ import Vue from 'vue';
import '@fortawesome/fontawesome-free/css/all.min.css';
import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
import 'buefy/dist/buefy.css';
//-- aircox

View File

@ -78,7 +78,7 @@ a.navbar-item.is-active {
.card-super-title {
position: absolute;
z-index: 1000;
font-size: 1.2em;
font-size: $size-6;
font-weight: $weight-bold;
padding: 0.2em;
top: 1em;

View File

@ -15,8 +15,8 @@ Including another URLconf
"""
# from django.conf.urls.i18n import i18n_patterns
from django.conf import settings
from django.urls import include, path, re_path
from django.contrib import admin
from django.urls import include, path, re_path
import aircox.urls