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): 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 .episode import Episode, Diffusion
from .log import Log from .log import Log
from .sound import Sound, Track from .sound import Sound, Track
from .station import Station, Port from .station import Station
from . import signals 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 Clean-up later diffusions when a program becomes inactive
""" """
if not instance.active: if not instance.active:
Diffusion.objects.program(instance).after(tz.now()).delete() Diffusion.object.program(instance).after(tz.now()).delete()
Episode.object.program(instance).filter(diffusion__isnull=True) \ Episode.object.parent(instance).filter(diffusion__isnull=True) \
.delete() .delete()
@ -94,7 +94,6 @@ def schedule_pre_delete(sender, instance, *args, **kwargs):
@receiver(signals.post_delete, sender=Diffusion) @receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs): def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, Episode.objects.filter(diffusion__isnull=True, content__isnull=True,
sound__isnull=True) \ sound__isnull=True).delete()
.delete()

View File

@ -1,7 +1,6 @@
import os import os
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from filer.fields.image import FilerImageField from filer.fields.image import FilerImageField
@ -9,7 +8,7 @@ from filer.fields.image import FilerImageField
from .. import settings from .. import settings
__all__ = ['Station', 'StationQuerySet', 'Port'] __all__ = ['Station', 'StationQuerySet']
class StationQuerySet(models.QuerySet): class StationQuerySet(models.QuerySet):
@ -22,6 +21,9 @@ class StationQuerySet(models.QuerySet):
return self.order_by('-default', 'pk').first() return self.order_by('-default', 'pk').first()
return self.filter(pk=station).first() return self.filter(pk=station).first()
def active(self):
return self.filter(active=True)
class Station(models.Model): class Station(models.Model):
""" """
@ -44,7 +46,12 @@ class Station(models.Model):
default = models.BooleanField( default = models.BooleanField(
_('default station'), _('default station'),
default=True, 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( logo = FilerImageField(
on_delete=models.SET_NULL, null=True, blank=True, on_delete=models.SET_NULL, null=True, blank=True,
@ -79,6 +86,20 @@ class Station(models.Model):
super().save(*args, **kwargs) 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): class Port(models.Model):
""" """
Represent an audio input/output for the audio stream Represent an audio input/output for the audio stream
@ -126,6 +147,14 @@ class Port(models.Model):
blank=True, null=True 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): def is_valid_type(self):
""" """
Return True if the type is available for the given direction. Return True if the type is available for the given direction.
@ -148,11 +177,3 @@ class Port(models.Model):
return super().save(*args, **kwargs) 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 os
import stat import stat

View File

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

View File

@ -234,7 +234,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "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 { .card-super-title {
position: absolute; position: absolute;
z-index: 1000; z-index: 1000;
font-size: 1.2em; font-size: 1rem;
font-weight: 700; font-weight: 700;
padding: 0.2em; padding: 0.2em;
top: 1em; top: 1em;

View File

@ -175,7 +175,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) { /***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict"; "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":
/*!*****************************************!*\ /*!*****************************************!*\
!*** ./node_modules/process/browser.js ***! !*** ./node_modules/process/browser.js ***!

View File

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

View File

@ -1,4 +1,3 @@
from django.contrib import admin
from django.urls import include, path, register_converter from django.urls import include, path, register_converter
from django.utils.translation import ugettext_lazy as _ 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 import admin
from django.contrib.auth.mixins import LoginRequiredMixin, \ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
PermissionRequiredMixin, UserPassesTestMixin
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic import ListView from django.views.generic import ListView
from ..models import Program
from .log import LogListView from .log import LogListView
__all__ = ['BaseAdminView', 'StatisticsView']
class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin): class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin):
title = '' title = ''
@ -27,6 +23,7 @@ class BaseAdminView(LoginRequiredMixin, UserPassesTestMixin):
class StatisticsView(BaseAdminView, LogListView, ListView): class StatisticsView(BaseAdminView, LogListView, ListView):
template_name = 'admin/aircox/statistics.html' template_name = 'admin/aircox/statistics.html'
redirect_date_url = 'tools-stats'
title = _('Statistics') title = _('Statistics')
date = None date = None
@ -34,26 +31,3 @@ class StatisticsView(BaseAdminView, LogListView, ListView):
return super().get_object_list(logs, True) 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 django.utils import timezone as tz
from rest_framework.generics import ListAPIView from rest_framework.generics import ListAPIView
from rest_framework import viewsets
from rest_framework.decorators import action
from ..models import Log from ..models import Log
from ..serializers import LogInfo, LogInfoSerializer from ..serializers import LogInfo, LogInfoSerializer
@ -44,4 +46,3 @@ class LogListAPIView(LogListMixin, BaseAPIView, ListAPIView):
return super().get_serializer(self.get_object_list(queryset, full), return super().get_serializer(self.get_object_list(queryset, full),
*args, **kwargs) *args, **kwargs)

View File

@ -51,10 +51,11 @@ class BaseView(TemplateResponseMixin, ContextMixin):
kwargs['sidebar_object_list'] = sidebar_object_list[:self.list_count] kwargs['sidebar_object_list'] = sidebar_object_list[:self.list_count]
kwargs['sidebar_list_url'] = self.get_sidebar_url() 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 = self.station.audio_streams
streams = streams and streams.split('\n') streams = streams and streams.split('\n')
kwargs['audio_streams'] = streams kwargs['audio_streams'] = streams
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -14,7 +14,6 @@ from .base import BaseView
__all__ = ['PageDetailView', 'PageListView'] __all__ = ['PageDetailView', 'PageListView']
# TODO: pagination: in template, only a limited number of pages displayed
class PageListView(BaseView, ListView): class PageListView(BaseView, ListView):
template_name = 'aircox/page_list.html' template_name = 'aircox/page_list.html'
item_template_name = 'aircox/widgets/page_item.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.template.loader import render_to_string
from django.utils import timezone as tz from django.utils import timezone as tz
from . import settings from aircox import settings
from .models import Port, Station, Sound from aircox.models import Station, Sound
from .connector import Connector from aircox.utils import to_seconds
from .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 # FIXME liquidsoap does not manage timezones -- we have to convert
# 'on_air' metadata we get from it into utc one in order to work # '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') 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: class Streamer:
connector = None connector = None
process = None process = None
station = None station = None
template_name = 'aircox/scripts/station.liq' template_name = 'aircox_streamer/scripts/station.liq'
path = None path = None
""" Config path """ """ Config path """
sources = None sources = None
@ -41,9 +101,16 @@ class Streamer:
# moment # moment
# on_air = None # on_air = None
# """ On-air request ids (rid) """ # """ 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.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.id = self.station.slug.replace('-', '_')
self.path = os.path.join(station.path, 'station.liq') self.path = os.path.join(station.path, 'station.liq')
self.connector = Connector(os.path.join(station.path, 'station.sock')) self.connector = Connector(os.path.join(station.path, 'station.sock'))
@ -63,6 +130,7 @@ class Streamer:
@property @property
def is_running(self): def is_running(self):
""" True if holds a running process """
if self.process is None: if self.process is None:
return False return False
@ -74,19 +142,6 @@ class Streamer:
logger.debug('process died with return code %s' % returncode) logger.debug('process died with return code %s' % returncode)
return False 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 ############################################### # Sources and config ###############################################
def send(self, *args, **kwargs): def send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) or '' return self.connector.send(*args, **kwargs) or ''
@ -121,9 +176,6 @@ class Streamer:
def fetch(self): def fetch(self):
""" Fetch data from liquidsoap """ """ Fetch data from liquidsoap """
if self.process is None:
return
for source in self.sources: for source in self.sources:
source.fetch() source.fetch()
@ -182,19 +234,11 @@ class Streamer:
self.process = None self.process = None
class Source: class Source(BaseMetadata):
controller = None controller = None
""" parent controller """ """ parent controller """
id = None id = None
""" source id """ """ source id """
uri = ''
""" source uri """
rid = None
""" request id """
air_time = None
""" on air time """
status = None
""" source status """
remaining = 0.0 remaining = 0.0
""" remaining time """ """ remaining time """
@ -202,16 +246,12 @@ class Source:
def station(self): def station(self):
return self.controller.station return self.controller.station
@property
def is_playing(self):
return self.status == 'playing'
# @property # @property
# def is_on_air(self): # def is_on_air(self):
# return self.rid is not None and self.rid in self.controller.on_air # return self.rid is not None and self.rid in self.controller.on_air
def __init__(self, controller, id=None): def __init__(self, controller=None, id=None, *args, **kwargs):
self.controller = controller super().__init__(controller, *args, **kwargs)
self.id = id self.id = id
def sync(self): def sync(self):
@ -219,23 +259,12 @@ class Source:
def fetch(self): def fetch(self):
data = self.controller.send(self.id, '.remaining') 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) data = self.controller.send(self.id, '.get', parse=True)
self.on_metadata(data if data and isinstance(data, dict) else {}) if data:
self.validate(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
def skip(self): def skip(self):
""" Skip the current source sound """ """ Skip the current source sound """
@ -271,15 +300,15 @@ class PlaylistSource(Source):
""" Get playlist's sounds queryset """ """ Get playlist's sounds queryset """
return self.program.sound_set.archive() return self.program.sound_set.archive()
def load_playlist(self): def get_playlist(self):
""" Load playlist """ """ Get playlist from db """
self.playlist = self.get_sound_queryset().paths() return self.get_sound_queryset().paths()
def write_playlist(self): def write_playlist(self, playlist=[]):
""" Write playlist file. """ """ Write playlist to file. """
os.makedirs(os.path.dirname(self.path), exist_ok=True) os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, 'w') as file: with open(self.path, 'w') as file:
file.write('\n'.join(self.playlist or [])) file.write('\n'.join(playlist or []))
def stream(self): def stream(self):
""" Return program's stream info if any (or None) as dict. """ """ Return program's stream info if any (or None) as dict. """
@ -296,15 +325,21 @@ class PlaylistSource(Source):
} }
def sync(self): def sync(self):
self.load_playlist() playlist = self.get_playlist()
self.write_playlist() self.write_playlist(playlist)
class QueueSource(Source): class QueueSource(Source):
queue = None queue = None
""" Source's queue (excluded on_air request) """ """ 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 """ """ Add the provided paths to source's play queue """
for path in paths: for path in paths:
self.controller.send(self.id, '_queue.push ', path) self.controller.send(self.id, '_queue.push ', path)
@ -312,4 +347,12 @@ class QueueSource(Source):
def fetch(self): def fetch(self):
super().fetch() super().fetch()
queue = self.controller.send(self.id, '_queue.queue').split(' ') 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.core.management.base import BaseCommand
from django.utils import timezone as tz 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.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.utils import date_range from aircox.utils import date_range
from aircox_streamer.liquidsoap import Streamer, PlaylistSource
# force using UTC # force using UTC
tz.activate(pytz.UTC) tz.activate(pytz.UTC)
@ -227,7 +228,7 @@ class Monitor:
def start_diff(self, source, diff): def start_diff(self, source, diff):
playlist = Sound.objects.episode(id=diff.episode_id).paths() 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, self.log(type=Log.TYPE_START, source=source.id, diffusion=diff,
comment=str(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/all.min.css';
import '@fortawesome/fontawesome-free/css/fontawesome.min.css'; import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
import 'buefy/dist/buefy.css';
//-- aircox //-- aircox

View File

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

View File

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