#137: Sound et EpisodeSound, dashboard UI improvements (into #121) (#138)

#137

Deployment: **Upgrade to Liquidsoap 2.4**: code has been adapted to work with liquidsoap 2.4

Co-authored-by: bkfox <thomas bkfox net>
Reviewed-on: #138
This commit is contained in:
Thomas Kairos 2024-04-05 18:45:15 +02:00
parent bda4efe336
commit a24318bc84
78 changed files with 25575 additions and 15800 deletions

View File

@ -2,9 +2,9 @@ from adminsortable2.admin import SortableAdminBase
from django.contrib import admin from django.contrib import admin
from django.forms import ModelForm from django.forms import ModelForm
from aircox.models import Episode from aircox.models import Episode, EpisodeSound
from .page import PageAdmin from .page import PageAdmin
from .sound import SoundInline, TrackInline from .sound import TrackInline
from .diffusion import DiffusionInline from .diffusion import DiffusionInline
@ -25,7 +25,7 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
search_fields = PageAdmin.search_fields + ("parent__title",) search_fields = PageAdmin.search_fields + ("parent__title",)
# readonly_fields = ('parent',) # readonly_fields = ('parent',)
inlines = [TrackInline, SoundInline, DiffusionInline] inlines = [TrackInline, DiffusionInline]
def add_view(self, request, object_id, form_url="", context=None): def add_view(self, request, object_id, form_url="", context=None):
context = context or {} context = context or {}
@ -38,3 +38,8 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
context["init_app"] = True context["init_app"] = True
context["init_el"] = "#inline-tracks" context["init_el"] = "#inline-tracks"
return super().change_view(request, object_id, form_url, context) return super().change_view(request, object_id, form_url, context)
@admin.register(EpisodeSound)
class EpisodeSoundAdmin(admin.ModelAdmin):
list_display = ("episode", "sound", "broadcast")

View File

@ -25,16 +25,16 @@ class SoundTrackInline(TrackInline):
class SoundInline(admin.TabularInline): class SoundInline(admin.TabularInline):
model = Sound model = Sound
fields = [ fields = [
"type",
"name", "name",
"audio", "audio",
"duration", "duration",
"broadcast",
"is_good_quality", "is_good_quality",
"is_public", "is_public",
"is_downloadable", "is_downloadable",
"is_removed", "is_removed",
] ]
readonly_fields = ["type", "audio", "duration", "is_good_quality"] readonly_fields = ["broadcast", "audio", "duration", "is_good_quality"]
extra = 0 extra = 0
max_num = 0 max_num = 0
@ -53,20 +53,20 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
list_display = [ list_display = [
"id", "id",
"name", "name",
"related", # "related",
"type", "broadcast",
"duration", "duration",
"is_public", "is_public",
"is_good_quality", "is_good_quality",
"is_downloadable", "is_downloadable",
"audio", "audio",
] ]
list_filter = ("type", "is_good_quality", "is_public") list_filter = ("broadcast", "is_good_quality", "is_public")
list_editable = ["name", "is_public", "is_downloadable"] list_editable = ["name", "is_public", "is_downloadable"]
search_fields = ["name", "program__title"] search_fields = ["name", "program__title"]
fieldsets = [ fieldsets = [
(None, {"fields": ["name", "file", "type", "program", "episode"]}), (None, {"fields": ["name", "file", "broadcast", "program", "episode"]}),
( (
None, None,
{ {
@ -80,14 +80,16 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
}, },
), ),
] ]
readonly_fields = ("file", "duration", "type") readonly_fields = ("file", "duration", "is_removed")
inlines = [SoundTrackInline] inlines = [SoundTrackInline]
def related(self, obj): def related(self, obj):
# TODO: link to episode or program edit # # TODO: link to episode or program edit
return obj.episode.title if obj.episode else obj.program.title if obj.program else "" return obj.program.title if obj.program else ""
related.short_description = _("Program / Episode") # return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
related.short_description = _("Program")
def audio(self, obj): def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) if not obj.is_removed else "" return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) if not obj.is_removed else ""

View File

@ -140,7 +140,7 @@ class Settings(BaseSettings):
"""In days, minimal age of a log before it is archived.""" """In days, minimal age of a log before it is archived."""
# --- Sounds # --- Sounds
SOUND_ARCHIVES_SUBDIR = "archives" SOUND_BROADCASTS_SUBDIR = "archives"
"""Sub directory used for the complete episode sounds.""" """Sub directory used for the complete episode sounds."""
SOUND_EXCERPTS_SUBDIR = "excerpts" SOUND_EXCERPTS_SUBDIR = "excerpts"
"""Sub directory used for the excerpts of the episode.""" """Sub directory used for the excerpts of the episode."""

View File

@ -21,23 +21,18 @@ parameters given by the setting SOUND_QUALITY. This script requires
Sox (and soxi). Sox (and soxi).
""" """
import logging import logging
import os
import re
from datetime import date
import mutagen
from django.conf import settings as conf from django.conf import settings as conf
from django.utils import timezone as tz
from django.utils.translation import gettext as _
from aircox import utils from aircox.models import Program, Sound, EpisodeSound
from aircox.models import Program, Sound, Track
from .playlist_import import PlaylistImport
logger = logging.getLogger("aircox.commands") logger = logging.getLogger("aircox.commands")
__all__ = ("SoundFile",)
class SoundFile: class SoundFile:
"""Handle synchronisation between sounds on files and database.""" """Handle synchronisation between sounds on files and database."""
@ -61,153 +56,40 @@ class SoundFile:
def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs): def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs):
"""Update related sound model and save it.""" """Update related sound model and save it."""
if deleted: if deleted:
return self._on_delete(self.path, keep_deleted) self.sound = self._on_delete(self.path, keep_deleted)
return self.sound
# FIXME: sound.program as not null program = sound and sound.program or Program.get_from_path(self.path)
if not program: if program:
program = Program.get_from_path(self.path) kwargs["program_id"] = program.pk
logger.debug('program from path "%s" -> %s', self.path, program)
kwargs["program_id"] = program.pk
if sound: created = False
created = False if not sound:
else:
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs) sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs)
self.sound = sound self.sound = sound
self.path_info = self.read_path(self.path) sound.sync_fs(on_update=True, find_playlist=True)
sound.program = program
if created or sound.check_on_file():
sound.name = self.path_info.get("name")
self.info = self.read_file_info()
if self.info is not None:
sound.duration = utils.seconds_to_time(self.info.info.length)
# check for episode
if sound.episode is None and "year" in self.path_info:
sound.episode = self.find_episode(sound, self.path_info)
sound.save() sound.save()
# check for playlist if not sound.episodesound_set.all().exists():
self.find_playlist(sound) self.create_episode_sound(sound)
return sound return sound
def create_episode_sound(self, sound):
episode = sound.find_episode()
if episode:
# FIXME: position from name
item = EpisodeSound(
episode=episode, sound=sound, position=episode.episodesound_set.all().count(), broadcast=sound.broadcast
)
item.save()
def _on_delete(self, path, keep_deleted): def _on_delete(self, path, keep_deleted):
# TODO: remove from db on delete sound = None
if keep_deleted: if keep_deleted:
sound = Sound.objects.path(self.path).first() if sound := Sound.objects.path(self.path).first():
if sound: sound.is_removed = True
if keep_deleted: sound.save(sync=False)
sound.is_removed = True elif sound := Sound.objects.path(self.path):
sound.check_on_file() sound.delete()
sound.save() return sound
return sound
else:
Sound.objects.path(self.path).delete()
def read_path(self, path):
"""Parse path name returning dictionary of extracted info. It can
contain:
- `year`, `month`, `day`: diffusion date
- `hour`, `minute`: diffusion time
- `n`: sound arbitrary number (used for sound ordering)
- `name`: cleaned name extracted or file name (without extension)
"""
basename = os.path.basename(path)
basename = os.path.splitext(basename)[0]
reg_match = self._path_re.search(basename)
if reg_match:
info = reg_match.groupdict()
for k in ("year", "month", "day", "hour", "minute", "n"):
if info.get(k) is not None:
info[k] = int(info[k])
name = info.get("name")
info["name"] = name and self._into_name(name) or basename
else:
info = {"name": basename}
return info
_path_re = re.compile(
"^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
"(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
"(_(?P<n>[0-9]+))?"
"_?[ -]*(?P<name>.*)$"
)
def _into_name(self, name):
name = name.replace("_", " ")
return " ".join(r.capitalize() for r in name.split(" "))
def read_file_info(self):
"""Read file information and metadata."""
try:
if os.path.exists(self.path):
return mutagen.File(self.path)
except Exception:
pass
return None
def find_episode(self, sound, path_info):
"""For a given program, check if there is an initial diffusion to
associate to, using the date info we have. Update self.sound and save
it consequently.
We only allow initial diffusion since there should be no rerun.
"""
program, pi = sound.program, path_info
if "year" not in pi or not sound or sound.episode:
return None
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
if pi.get("hour") is not None:
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
at = tz.make_aware(at)
else:
at = date(year, month, day)
diffusion = program.diffusion_set.at(at).first()
if not diffusion:
return None
logger.debug("%s <--> %s", sound.file.name, str(diffusion.episode))
return diffusion.episode
def find_playlist(self, sound=None, use_meta=True):
"""Find a playlist file corresponding to the sound path, such as:
my_sound.ogg => my_sound.csv.
Use sound's file metadata if no corresponding playlist has been
found and `use_meta` is True.
"""
if sound is None:
sound = self.sound
if sound.track_set.count() > 1:
return
# import playlist
path_noext, ext = os.path.splitext(self.sound.file.path)
path = path_noext + ".csv"
if os.path.exists(path):
PlaylistImport(path, sound=sound).run()
# use metadata
elif use_meta:
if self.info is None:
self.read_file_info()
if self.info and self.info.tags:
tags = self.info.tags
title, artist, album, year = tuple(
t and ", ".join(t) for t in (tags.get(k) for k in ("title", "artist", "album", "year"))
)
title = title or (self.path_info and self.path_info.get("name")) or os.path.basename(path_noext)
info = "{} ({})".format(album, year) if album and year else album or year or ""
track = Track(
sound=sound,
position=int(tags.get("tracknumber", 0)),
title=title,
artist=artist or _("unknown"),
info=info,
)
track.save()

View File

@ -105,8 +105,7 @@ class MoveTask(Task):
def __call__(self, event, **kw): def __call__(self, event, **kw):
sound = Sound.objects.filter(file=event.src_path).first() sound = Sound.objects.filter(file=event.src_path).first()
if sound: if sound:
kw["sound"] = sound kw = {**kw, "sound": sound, "path": event.src_path}
kw["path"] = event.src_path
else: else:
kw["path"] = event.dest_path kw["path"] = event.dest_path
return super().__call__(event, **kw) return super().__call__(event, **kw)
@ -214,15 +213,15 @@ class SoundMonitor:
logger.info(f"#{program.id} {program.title}") logger.info(f"#{program.id} {program.title}")
self.scan_for_program( self.scan_for_program(
program, program,
settings.SOUND_ARCHIVES_SUBDIR, settings.SOUND_BROADCASTS_SUBDIR,
logger=logger, logger=logger,
type=Sound.TYPE_ARCHIVE, broadcast=True,
) )
self.scan_for_program( self.scan_for_program(
program, program,
settings.SOUND_EXCERPTS_SUBDIR, settings.SOUND_EXCERPTS_SUBDIR,
logger=logger, logger=logger,
type=Sound.TYPE_EXCERPT, broadcast=False,
) )
dirs.append(program.abspath) dirs.append(program.abspath)
return dirs return dirs
@ -255,7 +254,7 @@ class SoundMonitor:
"""Only check for the sound existence or update.""" """Only check for the sound existence or update."""
# check files # check files
for sound in qs: for sound in qs:
if sound.check_on_file(): if sound.sync_fs(on_update=True):
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs) SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
_running = False _running = False
@ -267,15 +266,15 @@ class SoundMonitor:
"""Run in monitor mode.""" """Run in monitor mode."""
with futures.ThreadPoolExecutor() as pool: with futures.ThreadPoolExecutor() as pool:
archives_handler = MonitorHandler( archives_handler = MonitorHandler(
settings.SOUND_ARCHIVES_SUBDIR, settings.SOUND_BROADCASTS_SUBDIR,
pool, pool,
type=Sound.TYPE_ARCHIVE, broadcast=True,
logger=logger, logger=logger,
) )
excerpts_handler = MonitorHandler( excerpts_handler = MonitorHandler(
settings.SOUND_EXCERPTS_SUBDIR, settings.SOUND_EXCERPTS_SUBDIR,
pool, pool,
type=Sound.TYPE_EXCERPT, broadcast=False,
logger=logger, logger=logger,
) )

View File

@ -50,13 +50,13 @@ class ImageFilterSet(filters.FilterSet):
class SoundFilterSet(filters.FilterSet): class SoundFilterSet(filters.FilterSet):
station = filters.NumberFilter(field_name="program__station__id") station = filters.NumberFilter(field_name="program__station__id")
program = filters.NumberFilter(field_name="program_id") program = filters.NumberFilter(field_name="program_id")
episode = filters.NumberFilter(field_name="episode_id") # episode = filters.NumberFilter(field_name="episode_id")
search = filters.CharFilter(field_name="search", method="search_filter") search = filters.CharFilter(field_name="search", method="search_filter")
class Meta: class Meta:
model = models.Sound model = models.Sound
fields = { fields = {
"episode": ["in", "exact", "isnull"], # "episode": ["in", "exact", "isnull"],
} }
def search_filter(self, queryset, name, value): def search_filter(self, queryset, name, value):

View File

@ -5,7 +5,7 @@ from django.forms.models import modelformset_factory
from aircox import models from aircox import models
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "SoundFormSet", "TrackFormSet") __all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "EpisodeSoundFormSet", "TrackFormSet")
class CommentForm(forms.ModelForm): class CommentForm(forms.ModelForm):
@ -44,23 +44,12 @@ class EpisodeForm(PageForm):
fields = PageForm.Meta.fields fields = PageForm.Meta.fields
# def save(self, commit=True):
# file_obj = self.cleaned_data["new_podcast"]
# if file_obj:
# obj, _ = File.objects.get_or_create(original_filename=file_obj.name, file=file_obj)
# sound_file = SoundFile(obj.path)
# sound_file.sync(
# program=self.instance.program, episode=self.instance, type=0, is_public=True, is_downloadable=True
# )
# super().save(commit=commit)
class SoundForm(forms.ModelForm): class SoundForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView.""" """SoundForm used in EpisodeUpdateView."""
class Meta: class Meta:
model = models.Sound model = models.Sound
fields = ["name", "program", "episode", "file", "type", "position", "duration", "is_public", "is_downloadable"] fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm): class SoundCreateForm(forms.ModelForm):
@ -68,33 +57,40 @@ class SoundCreateForm(forms.ModelForm):
class Meta: class Meta:
model = models.Sound model = models.Sound
fields = ["name", "episode", "program", "file", "type", "is_public", "is_downloadable"] fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
widgets = {"program": forms.HiddenInput()}
TrackFormSet = modelformset_factory( TrackFormSet = modelformset_factory(
models.Track, models.Track,
fields=[ fields=[
"position", "position",
"episode",
"artist", "artist",
"title", "title",
"tags", "tags",
], ],
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
can_delete=True, can_delete=True,
extra=0, extra=0,
) )
"""Track formset used in EpisodeUpdateView.""" """Track formset used in EpisodeUpdateView."""
SoundFormSet = modelformset_factory(
models.Sound, EpisodeSoundFormSet = modelformset_factory(
fields=[ models.EpisodeSound,
fields=(
"position", "position",
"name", "episode",
"type", "sound",
"is_public", "broadcast",
"is_downloadable", ),
"duration", widgets={
], "broadcast": forms.CheckboxInput(),
"episode": forms.HiddenInput(),
# "sound": forms.HiddenInput(),
"position": forms.HiddenInput(),
},
can_delete=True, can_delete=True,
extra=0, extra=0,
) )
"""Sound formset used in EpisodeUpdateView."""

View File

@ -0,0 +1,633 @@
# Generated by Django 4.2.9 on 2024-03-15 19:56
import aircox.models.schedule
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0022_set_group_ownership"),
]
operations = [
migrations.AddField(
model_name="station",
name="legal_label",
field=models.CharField(
blank=True,
default="",
help_text="Displayed at the bottom of pages.",
max_length=64,
verbose_name="Legal label",
),
),
migrations.AlterField(
model_name="schedule",
name="timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
("Africa/Accra", "Africa/Accra"),
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
("Africa/Algiers", "Africa/Algiers"),
("Africa/Asmara", "Africa/Asmara"),
("Africa/Asmera", "Africa/Asmera"),
("Africa/Bamako", "Africa/Bamako"),
("Africa/Bangui", "Africa/Bangui"),
("Africa/Banjul", "Africa/Banjul"),
("Africa/Bissau", "Africa/Bissau"),
("Africa/Blantyre", "Africa/Blantyre"),
("Africa/Brazzaville", "Africa/Brazzaville"),
("Africa/Bujumbura", "Africa/Bujumbura"),
("Africa/Cairo", "Africa/Cairo"),
("Africa/Casablanca", "Africa/Casablanca"),
("Africa/Ceuta", "Africa/Ceuta"),
("Africa/Conakry", "Africa/Conakry"),
("Africa/Dakar", "Africa/Dakar"),
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
("Africa/Djibouti", "Africa/Djibouti"),
("Africa/Douala", "Africa/Douala"),
("Africa/El_Aaiun", "Africa/El_Aaiun"),
("Africa/Freetown", "Africa/Freetown"),
("Africa/Gaborone", "Africa/Gaborone"),
("Africa/Harare", "Africa/Harare"),
("Africa/Johannesburg", "Africa/Johannesburg"),
("Africa/Juba", "Africa/Juba"),
("Africa/Kampala", "Africa/Kampala"),
("Africa/Khartoum", "Africa/Khartoum"),
("Africa/Kigali", "Africa/Kigali"),
("Africa/Kinshasa", "Africa/Kinshasa"),
("Africa/Lagos", "Africa/Lagos"),
("Africa/Libreville", "Africa/Libreville"),
("Africa/Lome", "Africa/Lome"),
("Africa/Luanda", "Africa/Luanda"),
("Africa/Lubumbashi", "Africa/Lubumbashi"),
("Africa/Lusaka", "Africa/Lusaka"),
("Africa/Malabo", "Africa/Malabo"),
("Africa/Maputo", "Africa/Maputo"),
("Africa/Maseru", "Africa/Maseru"),
("Africa/Mbabane", "Africa/Mbabane"),
("Africa/Mogadishu", "Africa/Mogadishu"),
("Africa/Monrovia", "Africa/Monrovia"),
("Africa/Nairobi", "Africa/Nairobi"),
("Africa/Ndjamena", "Africa/Ndjamena"),
("Africa/Niamey", "Africa/Niamey"),
("Africa/Nouakchott", "Africa/Nouakchott"),
("Africa/Ouagadougou", "Africa/Ouagadougou"),
("Africa/Porto-Novo", "Africa/Porto-Novo"),
("Africa/Sao_Tome", "Africa/Sao_Tome"),
("Africa/Timbuktu", "Africa/Timbuktu"),
("Africa/Tripoli", "Africa/Tripoli"),
("Africa/Tunis", "Africa/Tunis"),
("Africa/Windhoek", "Africa/Windhoek"),
("America/Adak", "America/Adak"),
("America/Anchorage", "America/Anchorage"),
("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"),
("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"),
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"),
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"),
("America/Argentina/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
("America/Aruba", "America/Aruba"),
("America/Asuncion", "America/Asuncion"),
("America/Atikokan", "America/Atikokan"),
("America/Atka", "America/Atka"),
("America/Bahia", "America/Bahia"),
("America/Bahia_Banderas", "America/Bahia_Banderas"),
("America/Barbados", "America/Barbados"),
("America/Belem", "America/Belem"),
("America/Belize", "America/Belize"),
("America/Blanc-Sablon", "America/Blanc-Sablon"),
("America/Boa_Vista", "America/Boa_Vista"),
("America/Bogota", "America/Bogota"),
("America/Boise", "America/Boise"),
("America/Buenos_Aires", "America/Buenos_Aires"),
("America/Cambridge_Bay", "America/Cambridge_Bay"),
("America/Campo_Grande", "America/Campo_Grande"),
("America/Cancun", "America/Cancun"),
("America/Caracas", "America/Caracas"),
("America/Catamarca", "America/Catamarca"),
("America/Cayenne", "America/Cayenne"),
("America/Cayman", "America/Cayman"),
("America/Chicago", "America/Chicago"),
("America/Chihuahua", "America/Chihuahua"),
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
("America/Coral_Harbour", "America/Coral_Harbour"),
("America/Cordoba", "America/Cordoba"),
("America/Costa_Rica", "America/Costa_Rica"),
("America/Creston", "America/Creston"),
("America/Cuiaba", "America/Cuiaba"),
("America/Curacao", "America/Curacao"),
("America/Danmarkshavn", "America/Danmarkshavn"),
("America/Dawson", "America/Dawson"),
("America/Dawson_Creek", "America/Dawson_Creek"),
("America/Denver", "America/Denver"),
("America/Detroit", "America/Detroit"),
("America/Dominica", "America/Dominica"),
("America/Edmonton", "America/Edmonton"),
("America/Eirunepe", "America/Eirunepe"),
("America/El_Salvador", "America/El_Salvador"),
("America/Ensenada", "America/Ensenada"),
("America/Fort_Nelson", "America/Fort_Nelson"),
("America/Fort_Wayne", "America/Fort_Wayne"),
("America/Fortaleza", "America/Fortaleza"),
("America/Glace_Bay", "America/Glace_Bay"),
("America/Godthab", "America/Godthab"),
("America/Goose_Bay", "America/Goose_Bay"),
("America/Grand_Turk", "America/Grand_Turk"),
("America/Grenada", "America/Grenada"),
("America/Guadeloupe", "America/Guadeloupe"),
("America/Guatemala", "America/Guatemala"),
("America/Guayaquil", "America/Guayaquil"),
("America/Guyana", "America/Guyana"),
("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
("America/Indiana/Vevay", "America/Indiana/Vevay"),
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
("America/Indiana/Winamac", "America/Indiana/Winamac"),
("America/Indianapolis", "America/Indianapolis"),
("America/Inuvik", "America/Inuvik"),
("America/Iqaluit", "America/Iqaluit"),
("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"),
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
("America/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"),
("America/Lima", "America/Lima"),
("America/Los_Angeles", "America/Los_Angeles"),
("America/Louisville", "America/Louisville"),
("America/Lower_Princes", "America/Lower_Princes"),
("America/Maceio", "America/Maceio"),
("America/Managua", "America/Managua"),
("America/Manaus", "America/Manaus"),
("America/Marigot", "America/Marigot"),
("America/Martinique", "America/Martinique"),
("America/Matamoros", "America/Matamoros"),
("America/Mazatlan", "America/Mazatlan"),
("America/Mendoza", "America/Mendoza"),
("America/Menominee", "America/Menominee"),
("America/Merida", "America/Merida"),
("America/Metlakatla", "America/Metlakatla"),
("America/Mexico_City", "America/Mexico_City"),
("America/Miquelon", "America/Miquelon"),
("America/Moncton", "America/Moncton"),
("America/Monterrey", "America/Monterrey"),
("America/Montevideo", "America/Montevideo"),
("America/Montreal", "America/Montreal"),
("America/Montserrat", "America/Montserrat"),
("America/Nassau", "America/Nassau"),
("America/New_York", "America/New_York"),
("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
("America/North_Dakota/Center", "America/North_Dakota/Center"),
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"),
("America/Nuuk", "America/Nuuk"),
("America/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"),
("America/Paramaribo", "America/Paramaribo"),
("America/Phoenix", "America/Phoenix"),
("America/Port-au-Prince", "America/Port-au-Prince"),
("America/Port_of_Spain", "America/Port_of_Spain"),
("America/Porto_Acre", "America/Porto_Acre"),
("America/Porto_Velho", "America/Porto_Velho"),
("America/Puerto_Rico", "America/Puerto_Rico"),
("America/Punta_Arenas", "America/Punta_Arenas"),
("America/Rainy_River", "America/Rainy_River"),
("America/Rankin_Inlet", "America/Rankin_Inlet"),
("America/Recife", "America/Recife"),
("America/Regina", "America/Regina"),
("America/Resolute", "America/Resolute"),
("America/Rio_Branco", "America/Rio_Branco"),
("America/Rosario", "America/Rosario"),
("America/Santa_Isabel", "America/Santa_Isabel"),
("America/Santarem", "America/Santarem"),
("America/Santiago", "America/Santiago"),
("America/Santo_Domingo", "America/Santo_Domingo"),
("America/Sao_Paulo", "America/Sao_Paulo"),
("America/Scoresbysund", "America/Scoresbysund"),
("America/Shiprock", "America/Shiprock"),
("America/Sitka", "America/Sitka"),
("America/St_Barthelemy", "America/St_Barthelemy"),
("America/St_Johns", "America/St_Johns"),
("America/St_Kitts", "America/St_Kitts"),
("America/St_Lucia", "America/St_Lucia"),
("America/St_Thomas", "America/St_Thomas"),
("America/St_Vincent", "America/St_Vincent"),
("America/Swift_Current", "America/Swift_Current"),
("America/Tegucigalpa", "America/Tegucigalpa"),
("America/Thule", "America/Thule"),
("America/Thunder_Bay", "America/Thunder_Bay"),
("America/Tijuana", "America/Tijuana"),
("America/Toronto", "America/Toronto"),
("America/Tortola", "America/Tortola"),
("America/Vancouver", "America/Vancouver"),
("America/Virgin", "America/Virgin"),
("America/Whitehorse", "America/Whitehorse"),
("America/Winnipeg", "America/Winnipeg"),
("America/Yakutat", "America/Yakutat"),
("America/Yellowknife", "America/Yellowknife"),
("Antarctica/Casey", "Antarctica/Casey"),
("Antarctica/Davis", "Antarctica/Davis"),
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
("Antarctica/Macquarie", "Antarctica/Macquarie"),
("Antarctica/Mawson", "Antarctica/Mawson"),
("Antarctica/McMurdo", "Antarctica/McMurdo"),
("Antarctica/Palmer", "Antarctica/Palmer"),
("Antarctica/Rothera", "Antarctica/Rothera"),
("Antarctica/South_Pole", "Antarctica/South_Pole"),
("Antarctica/Syowa", "Antarctica/Syowa"),
("Antarctica/Troll", "Antarctica/Troll"),
("Antarctica/Vostok", "Antarctica/Vostok"),
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
("Asia/Aden", "Asia/Aden"),
("Asia/Almaty", "Asia/Almaty"),
("Asia/Amman", "Asia/Amman"),
("Asia/Anadyr", "Asia/Anadyr"),
("Asia/Aqtau", "Asia/Aqtau"),
("Asia/Aqtobe", "Asia/Aqtobe"),
("Asia/Ashgabat", "Asia/Ashgabat"),
("Asia/Ashkhabad", "Asia/Ashkhabad"),
("Asia/Atyrau", "Asia/Atyrau"),
("Asia/Baghdad", "Asia/Baghdad"),
("Asia/Bahrain", "Asia/Bahrain"),
("Asia/Baku", "Asia/Baku"),
("Asia/Bangkok", "Asia/Bangkok"),
("Asia/Barnaul", "Asia/Barnaul"),
("Asia/Beirut", "Asia/Beirut"),
("Asia/Bishkek", "Asia/Bishkek"),
("Asia/Brunei", "Asia/Brunei"),
("Asia/Calcutta", "Asia/Calcutta"),
("Asia/Chita", "Asia/Chita"),
("Asia/Choibalsan", "Asia/Choibalsan"),
("Asia/Chongqing", "Asia/Chongqing"),
("Asia/Chungking", "Asia/Chungking"),
("Asia/Colombo", "Asia/Colombo"),
("Asia/Dacca", "Asia/Dacca"),
("Asia/Damascus", "Asia/Damascus"),
("Asia/Dhaka", "Asia/Dhaka"),
("Asia/Dili", "Asia/Dili"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Dushanbe", "Asia/Dushanbe"),
("Asia/Famagusta", "Asia/Famagusta"),
("Asia/Gaza", "Asia/Gaza"),
("Asia/Harbin", "Asia/Harbin"),
("Asia/Hebron", "Asia/Hebron"),
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
("Asia/Hong_Kong", "Asia/Hong_Kong"),
("Asia/Hovd", "Asia/Hovd"),
("Asia/Irkutsk", "Asia/Irkutsk"),
("Asia/Istanbul", "Asia/Istanbul"),
("Asia/Jakarta", "Asia/Jakarta"),
("Asia/Jayapura", "Asia/Jayapura"),
("Asia/Jerusalem", "Asia/Jerusalem"),
("Asia/Kabul", "Asia/Kabul"),
("Asia/Kamchatka", "Asia/Kamchatka"),
("Asia/Karachi", "Asia/Karachi"),
("Asia/Kashgar", "Asia/Kashgar"),
("Asia/Kathmandu", "Asia/Kathmandu"),
("Asia/Katmandu", "Asia/Katmandu"),
("Asia/Khandyga", "Asia/Khandyga"),
("Asia/Kolkata", "Asia/Kolkata"),
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
("Asia/Kuching", "Asia/Kuching"),
("Asia/Kuwait", "Asia/Kuwait"),
("Asia/Macao", "Asia/Macao"),
("Asia/Macau", "Asia/Macau"),
("Asia/Magadan", "Asia/Magadan"),
("Asia/Makassar", "Asia/Makassar"),
("Asia/Manila", "Asia/Manila"),
("Asia/Muscat", "Asia/Muscat"),
("Asia/Nicosia", "Asia/Nicosia"),
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
("Asia/Novosibirsk", "Asia/Novosibirsk"),
("Asia/Omsk", "Asia/Omsk"),
("Asia/Oral", "Asia/Oral"),
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
("Asia/Pontianak", "Asia/Pontianak"),
("Asia/Pyongyang", "Asia/Pyongyang"),
("Asia/Qatar", "Asia/Qatar"),
("Asia/Qostanay", "Asia/Qostanay"),
("Asia/Qyzylorda", "Asia/Qyzylorda"),
("Asia/Rangoon", "Asia/Rangoon"),
("Asia/Riyadh", "Asia/Riyadh"),
("Asia/Saigon", "Asia/Saigon"),
("Asia/Sakhalin", "Asia/Sakhalin"),
("Asia/Samarkand", "Asia/Samarkand"),
("Asia/Seoul", "Asia/Seoul"),
("Asia/Shanghai", "Asia/Shanghai"),
("Asia/Singapore", "Asia/Singapore"),
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
("Asia/Taipei", "Asia/Taipei"),
("Asia/Tashkent", "Asia/Tashkent"),
("Asia/Tbilisi", "Asia/Tbilisi"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
("Asia/Thimbu", "Asia/Thimbu"),
("Asia/Thimphu", "Asia/Thimphu"),
("Asia/Tokyo", "Asia/Tokyo"),
("Asia/Tomsk", "Asia/Tomsk"),
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
("Asia/Urumqi", "Asia/Urumqi"),
("Asia/Ust-Nera", "Asia/Ust-Nera"),
("Asia/Vientiane", "Asia/Vientiane"),
("Asia/Vladivostok", "Asia/Vladivostok"),
("Asia/Yakutsk", "Asia/Yakutsk"),
("Asia/Yangon", "Asia/Yangon"),
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
("Asia/Yerevan", "Asia/Yerevan"),
("Atlantic/Azores", "Atlantic/Azores"),
("Atlantic/Bermuda", "Atlantic/Bermuda"),
("Atlantic/Canary", "Atlantic/Canary"),
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
("Atlantic/Faeroe", "Atlantic/Faeroe"),
("Atlantic/Faroe", "Atlantic/Faroe"),
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
("Atlantic/Madeira", "Atlantic/Madeira"),
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
("Atlantic/St_Helena", "Atlantic/St_Helena"),
("Atlantic/Stanley", "Atlantic/Stanley"),
("Australia/ACT", "Australia/ACT"),
("Australia/Adelaide", "Australia/Adelaide"),
("Australia/Brisbane", "Australia/Brisbane"),
("Australia/Broken_Hill", "Australia/Broken_Hill"),
("Australia/Canberra", "Australia/Canberra"),
("Australia/Currie", "Australia/Currie"),
("Australia/Darwin", "Australia/Darwin"),
("Australia/Eucla", "Australia/Eucla"),
("Australia/Hobart", "Australia/Hobart"),
("Australia/LHI", "Australia/LHI"),
("Australia/Lindeman", "Australia/Lindeman"),
("Australia/Lord_Howe", "Australia/Lord_Howe"),
("Australia/Melbourne", "Australia/Melbourne"),
("Australia/NSW", "Australia/NSW"),
("Australia/North", "Australia/North"),
("Australia/Perth", "Australia/Perth"),
("Australia/Queensland", "Australia/Queensland"),
("Australia/South", "Australia/South"),
("Australia/Sydney", "Australia/Sydney"),
("Australia/Tasmania", "Australia/Tasmania"),
("Australia/Victoria", "Australia/Victoria"),
("Australia/West", "Australia/West"),
("Australia/Yancowinna", "Australia/Yancowinna"),
("Brazil/Acre", "Brazil/Acre"),
("Brazil/DeNoronha", "Brazil/DeNoronha"),
("Brazil/East", "Brazil/East"),
("Brazil/West", "Brazil/West"),
("CET", "CET"),
("CST6CDT", "CST6CDT"),
("Canada/Atlantic", "Canada/Atlantic"),
("Canada/Central", "Canada/Central"),
("Canada/Eastern", "Canada/Eastern"),
("Canada/Mountain", "Canada/Mountain"),
("Canada/Newfoundland", "Canada/Newfoundland"),
("Canada/Pacific", "Canada/Pacific"),
("Canada/Saskatchewan", "Canada/Saskatchewan"),
("Canada/Yukon", "Canada/Yukon"),
("Chile/Continental", "Chile/Continental"),
("Chile/EasterIsland", "Chile/EasterIsland"),
("Cuba", "Cuba"),
("EET", "EET"),
("EST", "EST"),
("EST5EDT", "EST5EDT"),
("Egypt", "Egypt"),
("Eire", "Eire"),
("Etc/GMT", "Etc/GMT"),
("Etc/GMT+0", "Etc/GMT+0"),
("Etc/GMT+1", "Etc/GMT+1"),
("Etc/GMT+10", "Etc/GMT+10"),
("Etc/GMT+11", "Etc/GMT+11"),
("Etc/GMT+12", "Etc/GMT+12"),
("Etc/GMT+2", "Etc/GMT+2"),
("Etc/GMT+3", "Etc/GMT+3"),
("Etc/GMT+4", "Etc/GMT+4"),
("Etc/GMT+5", "Etc/GMT+5"),
("Etc/GMT+6", "Etc/GMT+6"),
("Etc/GMT+7", "Etc/GMT+7"),
("Etc/GMT+8", "Etc/GMT+8"),
("Etc/GMT+9", "Etc/GMT+9"),
("Etc/GMT-0", "Etc/GMT-0"),
("Etc/GMT-1", "Etc/GMT-1"),
("Etc/GMT-10", "Etc/GMT-10"),
("Etc/GMT-11", "Etc/GMT-11"),
("Etc/GMT-12", "Etc/GMT-12"),
("Etc/GMT-13", "Etc/GMT-13"),
("Etc/GMT-14", "Etc/GMT-14"),
("Etc/GMT-2", "Etc/GMT-2"),
("Etc/GMT-3", "Etc/GMT-3"),
("Etc/GMT-4", "Etc/GMT-4"),
("Etc/GMT-5", "Etc/GMT-5"),
("Etc/GMT-6", "Etc/GMT-6"),
("Etc/GMT-7", "Etc/GMT-7"),
("Etc/GMT-8", "Etc/GMT-8"),
("Etc/GMT-9", "Etc/GMT-9"),
("Etc/GMT0", "Etc/GMT0"),
("Etc/Greenwich", "Etc/Greenwich"),
("Etc/UCT", "Etc/UCT"),
("Etc/UTC", "Etc/UTC"),
("Etc/Universal", "Etc/Universal"),
("Etc/Zulu", "Etc/Zulu"),
("Europe/Amsterdam", "Europe/Amsterdam"),
("Europe/Andorra", "Europe/Andorra"),
("Europe/Astrakhan", "Europe/Astrakhan"),
("Europe/Athens", "Europe/Athens"),
("Europe/Belfast", "Europe/Belfast"),
("Europe/Belgrade", "Europe/Belgrade"),
("Europe/Berlin", "Europe/Berlin"),
("Europe/Bratislava", "Europe/Bratislava"),
("Europe/Brussels", "Europe/Brussels"),
("Europe/Bucharest", "Europe/Bucharest"),
("Europe/Budapest", "Europe/Budapest"),
("Europe/Busingen", "Europe/Busingen"),
("Europe/Chisinau", "Europe/Chisinau"),
("Europe/Copenhagen", "Europe/Copenhagen"),
("Europe/Dublin", "Europe/Dublin"),
("Europe/Gibraltar", "Europe/Gibraltar"),
("Europe/Guernsey", "Europe/Guernsey"),
("Europe/Helsinki", "Europe/Helsinki"),
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
("Europe/Istanbul", "Europe/Istanbul"),
("Europe/Jersey", "Europe/Jersey"),
("Europe/Kaliningrad", "Europe/Kaliningrad"),
("Europe/Kiev", "Europe/Kiev"),
("Europe/Kirov", "Europe/Kirov"),
("Europe/Kyiv", "Europe/Kyiv"),
("Europe/Lisbon", "Europe/Lisbon"),
("Europe/Ljubljana", "Europe/Ljubljana"),
("Europe/London", "Europe/London"),
("Europe/Luxembourg", "Europe/Luxembourg"),
("Europe/Madrid", "Europe/Madrid"),
("Europe/Malta", "Europe/Malta"),
("Europe/Mariehamn", "Europe/Mariehamn"),
("Europe/Minsk", "Europe/Minsk"),
("Europe/Monaco", "Europe/Monaco"),
("Europe/Moscow", "Europe/Moscow"),
("Europe/Nicosia", "Europe/Nicosia"),
("Europe/Oslo", "Europe/Oslo"),
("Europe/Paris", "Europe/Paris"),
("Europe/Podgorica", "Europe/Podgorica"),
("Europe/Prague", "Europe/Prague"),
("Europe/Riga", "Europe/Riga"),
("Europe/Rome", "Europe/Rome"),
("Europe/Samara", "Europe/Samara"),
("Europe/San_Marino", "Europe/San_Marino"),
("Europe/Sarajevo", "Europe/Sarajevo"),
("Europe/Saratov", "Europe/Saratov"),
("Europe/Simferopol", "Europe/Simferopol"),
("Europe/Skopje", "Europe/Skopje"),
("Europe/Sofia", "Europe/Sofia"),
("Europe/Stockholm", "Europe/Stockholm"),
("Europe/Tallinn", "Europe/Tallinn"),
("Europe/Tirane", "Europe/Tirane"),
("Europe/Tiraspol", "Europe/Tiraspol"),
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
("Europe/Uzhgorod", "Europe/Uzhgorod"),
("Europe/Vaduz", "Europe/Vaduz"),
("Europe/Vatican", "Europe/Vatican"),
("Europe/Vienna", "Europe/Vienna"),
("Europe/Vilnius", "Europe/Vilnius"),
("Europe/Volgograd", "Europe/Volgograd"),
("Europe/Warsaw", "Europe/Warsaw"),
("Europe/Zagreb", "Europe/Zagreb"),
("Europe/Zaporozhye", "Europe/Zaporozhye"),
("Europe/Zurich", "Europe/Zurich"),
("Factory", "Factory"),
("GB", "GB"),
("GB-Eire", "GB-Eire"),
("GMT", "GMT"),
("GMT+0", "GMT+0"),
("GMT-0", "GMT-0"),
("GMT0", "GMT0"),
("Greenwich", "Greenwich"),
("HST", "HST"),
("Hongkong", "Hongkong"),
("Iceland", "Iceland"),
("Indian/Antananarivo", "Indian/Antananarivo"),
("Indian/Chagos", "Indian/Chagos"),
("Indian/Christmas", "Indian/Christmas"),
("Indian/Cocos", "Indian/Cocos"),
("Indian/Comoro", "Indian/Comoro"),
("Indian/Kerguelen", "Indian/Kerguelen"),
("Indian/Mahe", "Indian/Mahe"),
("Indian/Maldives", "Indian/Maldives"),
("Indian/Mauritius", "Indian/Mauritius"),
("Indian/Mayotte", "Indian/Mayotte"),
("Indian/Reunion", "Indian/Reunion"),
("Iran", "Iran"),
("Israel", "Israel"),
("Jamaica", "Jamaica"),
("Japan", "Japan"),
("Kwajalein", "Kwajalein"),
("Libya", "Libya"),
("MET", "MET"),
("MST", "MST"),
("MST7MDT", "MST7MDT"),
("Mexico/BajaNorte", "Mexico/BajaNorte"),
("Mexico/BajaSur", "Mexico/BajaSur"),
("Mexico/General", "Mexico/General"),
("NZ", "NZ"),
("NZ-CHAT", "NZ-CHAT"),
("Navajo", "Navajo"),
("PRC", "PRC"),
("PST8PDT", "PST8PDT"),
("Pacific/Apia", "Pacific/Apia"),
("Pacific/Auckland", "Pacific/Auckland"),
("Pacific/Bougainville", "Pacific/Bougainville"),
("Pacific/Chatham", "Pacific/Chatham"),
("Pacific/Chuuk", "Pacific/Chuuk"),
("Pacific/Easter", "Pacific/Easter"),
("Pacific/Efate", "Pacific/Efate"),
("Pacific/Enderbury", "Pacific/Enderbury"),
("Pacific/Fakaofo", "Pacific/Fakaofo"),
("Pacific/Fiji", "Pacific/Fiji"),
("Pacific/Funafuti", "Pacific/Funafuti"),
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Johnston", "Pacific/Johnston"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
("Pacific/Kosrae", "Pacific/Kosrae"),
("Pacific/Kwajalein", "Pacific/Kwajalein"),
("Pacific/Majuro", "Pacific/Majuro"),
("Pacific/Marquesas", "Pacific/Marquesas"),
("Pacific/Midway", "Pacific/Midway"),
("Pacific/Nauru", "Pacific/Nauru"),
("Pacific/Niue", "Pacific/Niue"),
("Pacific/Norfolk", "Pacific/Norfolk"),
("Pacific/Noumea", "Pacific/Noumea"),
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
("Pacific/Palau", "Pacific/Palau"),
("Pacific/Pitcairn", "Pacific/Pitcairn"),
("Pacific/Pohnpei", "Pacific/Pohnpei"),
("Pacific/Ponape", "Pacific/Ponape"),
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
("Pacific/Rarotonga", "Pacific/Rarotonga"),
("Pacific/Saipan", "Pacific/Saipan"),
("Pacific/Samoa", "Pacific/Samoa"),
("Pacific/Tahiti", "Pacific/Tahiti"),
("Pacific/Tarawa", "Pacific/Tarawa"),
("Pacific/Tongatapu", "Pacific/Tongatapu"),
("Pacific/Truk", "Pacific/Truk"),
("Pacific/Wake", "Pacific/Wake"),
("Pacific/Wallis", "Pacific/Wallis"),
("Pacific/Yap", "Pacific/Yap"),
("Poland", "Poland"),
("Portugal", "Portugal"),
("ROC", "ROC"),
("ROK", "ROK"),
("Singapore", "Singapore"),
("Turkey", "Turkey"),
("UCT", "UCT"),
("US/Alaska", "US/Alaska"),
("US/Aleutian", "US/Aleutian"),
("US/Arizona", "US/Arizona"),
("US/Central", "US/Central"),
("US/East-Indiana", "US/East-Indiana"),
("US/Eastern", "US/Eastern"),
("US/Hawaii", "US/Hawaii"),
("US/Indiana-Starke", "US/Indiana-Starke"),
("US/Michigan", "US/Michigan"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Samoa", "US/Samoa"),
("UTC", "UTC"),
("Universal", "Universal"),
("W-SU", "W-SU"),
("WET", "WET"),
("Zulu", "Zulu"),
],
default=aircox.models.schedule.current_timezone_key,
help_text="timezone used for the date",
max_length=100,
verbose_name="timezone",
),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.9 on 2024-03-19 22:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("aircox", "0023_station_legal_label_alter_schedule_timezone"),
]
operations = [
migrations.RenameField(
model_name="usersettings",
old_name="playlist_editor_columns",
new_name="tracklist_editor_columns",
),
migrations.RenameField(
model_name="usersettings",
old_name="playlist_editor_sep",
new_name="tracklist_editor_sep",
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.9 on 2024-03-25 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more"),
]
operations = [
migrations.AddField(
model_name="sound",
name="is_removed",
field=models.BooleanField(default=False, help_text="file has been removed", verbose_name="removed"),
),
migrations.AlterField(
model_name="sound",
name="is_downloadable",
field=models.BooleanField(
default=False,
help_text="sound can be downloaded by visitors (sound must be public)",
verbose_name="downloadable",
),
),
migrations.AlterField(
model_name="sound",
name="is_public",
field=models.BooleanField(default=False, help_text="sound is available as podcast", verbose_name="public"),
),
migrations.AlterField(
model_name="sound",
name="type",
field=models.SmallIntegerField(choices=[(0, "other"), (1, "archive"), (2, "excerpt")], verbose_name="type"),
),
]

View File

@ -0,0 +1,162 @@
# Generated by Django 4.2.9 on 2024-03-26 02:53
import aircox.models.file
from django.db import migrations, models
import django.db.models.deletion
sounds_info = {}
def get_sounds_info(apps, schema_editor):
Sound = apps.get_model("aircox", "Sound")
objs = Sound.objects.filter(episode__isnull=False).values(
"pk",
"episode_id",
"position",
"type",
)
sounds_info.update({obj["pk"]: obj for obj in objs})
def restore_sounds_info(apps, schema_editor):
try:
Sound = apps.get_model("aircox", "Sound")
EpisodeSound = apps.get_model("aircox", "EpisodeSound")
TYPE_ARCHIVE = 0x01
TYPE_REMOVED = 0x03
episode_sounds = []
sounds = []
for sound in Sound.objects.all():
info = sounds_info.get(sound.pk)
if not info:
continue
sound.broadcast = info["type"] == TYPE_ARCHIVE
sound.is_removed = info["type"] == TYPE_REMOVED
sounds.append(sound)
if not sound.is_removed:
obj = EpisodeSound(
sound=sound,
episode_id=info["episode_id"],
position=info["position"],
broadcast=sound.broadcast,
)
episode_sounds.append(obj)
Sound.objects.bulk_update(sounds, ("broadcast", "is_removed"))
EpisodeSound.objects.bulk_create(episode_sounds)
print(f"\n>>> {len(sounds)} Sound have been updated.")
print(f">>> {len(episode_sounds)} EpisodeSound have been created.")
except Exception as err:
print(err)
import traceback
traceback.print_exc()
class Migration(migrations.Migration):
dependencies = [
("aircox", "0025_sound_is_removed_alter_sound_is_downloadable_and_more"),
]
operations = [
migrations.RunPython(get_sounds_info),
migrations.AlterModelOptions(
name="sound",
options={"verbose_name": "Sound file", "verbose_name_plural": "Sound files"},
),
migrations.RemoveField(
model_name="sound",
name="episode",
),
migrations.RemoveField(
model_name="sound",
name="position",
),
migrations.RemoveField(
model_name="sound",
name="type",
),
migrations.AddField(
model_name="sound",
name="broadcast",
field=models.BooleanField(
default=False, help_text="The sound is broadcasted on air", verbose_name="Broadcast"
),
),
migrations.AddField(
model_name="sound",
name="description",
field=models.TextField(blank=True, default="", max_length=256, verbose_name="description"),
),
migrations.AlterField(
model_name="sound",
name="file",
field=models.FileField(
db_index=True, max_length=256, upload_to=aircox.models.file.File._upload_to, verbose_name="file"
),
),
migrations.AlterField(
model_name="sound",
name="is_downloadable",
field=models.BooleanField(
default=False, help_text="sound can be downloaded by visitors", verbose_name="downloadable"
),
),
migrations.AlterField(
model_name="sound",
name="is_public",
field=models.BooleanField(default=False, help_text="file is publicly accessible", verbose_name="public"),
),
migrations.AlterField(
model_name="sound",
name="is_removed",
field=models.BooleanField(
db_index=True, default=False, help_text="file has been removed from server", verbose_name="removed"
),
),
migrations.AlterField(
model_name="sound",
name="name",
field=models.CharField(db_index=True, max_length=64, verbose_name="name"),
),
migrations.AlterField(
model_name="sound",
name="program",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="aircox.program",
verbose_name="Program",
),
),
migrations.CreateModel(
name="EpisodeSound",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"position",
models.PositiveSmallIntegerField(
default=0, help_text="position in the playlist", verbose_name="order"
),
),
(
"broadcast",
models.BooleanField(
blank=None, help_text="The sound is broadcasted on air", verbose_name="Broadcast"
),
),
("episode", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.episode")),
("sound", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.sound")),
],
options={
"verbose_name": "Episode Sound",
"verbose_name_plural": "Episode Sounds",
},
),
migrations.RunPython(restore_sounds_info),
]

View File

@ -1,7 +1,7 @@
from . import signals from . import signals
from .article import Article from .article import Article
from .diffusion import Diffusion, DiffusionQuerySet from .diffusion import Diffusion, DiffusionQuerySet
from .episode import Episode from .episode import Episode, EpisodeSound
from .log import Log, LogQuerySet from .log import Log, LogQuerySet
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
@ -14,16 +14,17 @@ from .user_settings import UserSettings
__all__ = ( __all__ = (
"signals", "signals",
"Article", "Article",
"Episode", "Category",
"Comment",
"Diffusion", "Diffusion",
"DiffusionQuerySet", "DiffusionQuerySet",
"Episode",
"EpisodeSound",
"Log", "Log",
"LogQuerySet", "LogQuerySet",
"Category",
"PageQuerySet", "PageQuerySet",
"Page", "Page",
"StaticPage", "StaticPage",
"Comment",
"NavItem", "NavItem",
"Program", "Program",
"ProgramQuerySet", "ProgramQuerySet",

View File

@ -200,31 +200,7 @@ class Diffusion(Rerun):
@property @property
def is_live(self): def is_live(self):
"""True if Diffusion is live (False if there are sounds files).""" """True if Diffusion is live (False if there are sounds files)."""
return self.type == self.TYPE_ON_AIR and not self.episode.sound_set.archive().count() return self.type == self.TYPE_ON_AIR and self.episode.episodesound_set.all().broadcast().empty()
def get_playlist(self, **types):
"""Returns sounds as a playlist (list of *local* archive file path).
The given arguments are passed to ``get_sounds``.
"""
from .sound import Sound
return list(
self.get_sounds(**types).filter(path__isnull=False, type=Sound.TYPE_ARCHIVE).values_list("path", flat=True)
)
def get_sounds(self, **types):
"""Return a queryset of sounds related to this diffusion, ordered by
type then path.
**types: filter on the given sound types name, as `archive=True`
"""
from .sound import Sound
sounds = (self.initial or self).sound_set.order_by("type", "path")
_in = [getattr(Sound.Type, name) for name, value in types.items() if value]
return sounds.filter(type__in=_in)
def is_date_in_range(self, date=None): def is_date_in_range(self, date=None):
"""Return true if the given date is in the diffusion's start-end """Return true if the given date is in the diffusion's start-end

View File

@ -1,18 +1,22 @@
import os
from django.conf import settings as d_settings
from django.db import models
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from easy_thumbnails.files import get_thumbnailer
from aircox.conf import settings from aircox.conf import settings
from .page import Page from .page import Page
from .program import ProgramChildQuerySet from .program import ProgramChildQuerySet
from .sound import Sound
__all__ = ("Episode",) __all__ = ("Episode",)
class EpisodeQuerySet(ProgramChildQuerySet): class EpisodeQuerySet(ProgramChildQuerySet):
def with_podcasts(self): def with_podcasts(self):
return self.filter(sound__is_public=True).distinct() return self.filter(episodesound__sound__is_public=True).distinct()
class Episode(Page): class Episode(Page):
@ -32,39 +36,21 @@ class Episode(Page):
@cached_property @cached_property
def podcasts(self): def podcasts(self):
"""Return serialized data about podcasts.""" """Return serialized data about podcasts."""
from ..serializers import PodcastSerializer query = self.episodesound_set.all().public().order_by("-broadcast", "position")
return self._to_podcasts(query)
query = self.sound_set.public().order_by("type")
return self._to_podcasts(query, PodcastSerializer)
@cached_property @cached_property
def sounds(self): def sounds(self):
"""Return serialized data about all related sounds.""" """Return serialized data about all related sounds."""
from ..serializers import SoundSerializer query = self.episodesound_set.all().order_by("-broadcast", "position")
return self._to_podcasts(query)
query = self.sound_set.order_by("type") def _to_podcasts(self, query):
return self._to_podcasts(query, SoundSerializer) from ..serializers import EpisodeSoundSerializer as serializer_class
def _to_podcasts(self, items, serializer_class): query = query.select_related("sound")
from .sound import Sound podcasts = [serializer_class(s).data for s in query]
podcasts = [serializer_class(s).data for s in items]
if self.cover:
options = {"size": (128, 128), "crop": "scale"}
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
else:
cover = None
archive_index = 1
for index, podcast in enumerate(podcasts): for index, podcast in enumerate(podcasts):
if podcast["type"] == Sound.TYPE_ARCHIVE:
if archive_index > 1:
podcast["name"] = f"{self.title} - {archive_index}"
else:
podcast["name"] = self.title
archive_index += 1
podcasts[index]["cover"] = cover
podcasts[index]["page_url"] = self.get_absolute_url() podcasts[index]["page_url"] = self.get_absolute_url()
podcasts[index]["page_title"] = self.title podcasts[index]["page_title"] = self.title
return podcasts return podcasts
@ -102,3 +88,54 @@ class Episode(Page):
else title else title
) )
return super().get_init_kwargs_from(page, title=title, program=page, **kwargs) return super().get_init_kwargs_from(page, title=title, program=page, **kwargs)
class EpisodeSoundQuerySet(models.QuerySet):
def episode(self, episode):
if isinstance(episode, int):
return self.filter(episode_id=episode)
return self.filter(episode=episode)
def available(self):
return self.filter(sound__is_removed=False)
def public(self):
return self.filter(sound__is_public=True)
def broadcast(self):
return self.available().filter(broadcast=True)
def playlist(self, order="position"):
# TODO: subquery expression
if order:
self = self.order_by(order)
query = self.filter(sound__file__isnull=False, sound__is_removed=False).values_list("sound__file", flat=True)
return [os.path.join(d_settings.MEDIA_ROOT, file) for file in query]
class EpisodeSound(models.Model):
"""Element of an episode playlist."""
episode = models.ForeignKey(Episode, on_delete=models.CASCADE)
sound = models.ForeignKey(Sound, on_delete=models.CASCADE)
position = models.PositiveSmallIntegerField(
_("order"),
default=0,
help_text=_("position in the playlist"),
)
broadcast = models.BooleanField(
_("Broadcast"),
blank=None,
help_text=_("The sound is broadcasted on air"),
)
objects = EpisodeSoundQuerySet.as_manager()
class Meta:
verbose_name = _("Episode Sound")
verbose_name_plural = _("Episode Sounds")
def save(self, *args, **kwargs):
if self.broadcast is None:
self.broadcast = self.sound.broadcast
super().save(*args, **kwargs)

150
aircox/models/file.py Normal file
View File

@ -0,0 +1,150 @@
import os
from pathlib import Path
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils import timezone as tz
from .program import Program
class FileQuerySet(models.QuerySet):
def station(self, station=None, id=None):
id = station.pk if id is None else id
return self.filter(program__station__id=id)
def available(self):
return self.exclude(is_removed=False)
def public(self):
"""Return sounds available as podcasts."""
return self.filter(is_public=True)
def path(self, paths):
if isinstance(paths, str):
return self.filter(file=paths.replace(settings.MEDIA_ROOT + "/", ""))
return self.filter(file__in=(p.replace(settings.MEDIA_ROOT + "/", "") for p in paths))
def search(self, query):
return self.filter(Q(name__icontains=query) | Q(file__icontains=query) | Q(program__title__icontains=query))
class File(models.Model):
def _upload_to(self, filename):
dir = self.program and self.program.path or self.default_upload_path
subdir = self.get_upload_dir()
if subdir:
return os.path.join(dir, subdir, filename)
return os.path.join(dir, filename)
program = models.ForeignKey(
Program,
models.SET_NULL,
verbose_name=_("Program"),
null=True,
blank=True,
)
file = models.FileField(
_("file"),
upload_to=_upload_to,
max_length=256,
db_index=True,
)
name = models.CharField(
_("name"),
max_length=64,
db_index=True,
)
description = models.TextField(
_("description"),
max_length=256,
blank=True,
default="",
)
mtime = models.DateTimeField(
_("modification time"),
blank=True,
null=True,
help_text=_("last modification date and time"),
)
is_public = models.BooleanField(
_("public"),
help_text=_("file is publicly accessible"),
default=False,
)
is_removed = models.BooleanField(
_("removed"),
help_text=_("file has been removed from server"),
default=False,
db_index=True,
)
class Meta:
abstract = True
objects = FileQuerySet.as_manager()
default_upload_path = Path(settings.MEDIA_ROOT)
"""Default upload directory when no program is provided."""
upload_dir = "uploads"
"""Upload sub-directory."""
@property
def url(self):
return self.file and self.file.url
def get_upload_dir(self):
return self.upload_dir
def get_mtime(self):
"""Get the last modification date from file."""
mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def file_updated(self):
"""Return True when file has been updated on filesystem."""
return self.mtime != self.get_mtime() or self.is_removed != (not self.file_exists())
def file_exists(self):
"""Return true if the file still exists."""
return os.path.exists(self.file.path)
def sync_fs(self, on_update=False):
"""Sync model to file on the filesystem.
:param bool on_update: only check if `file_updated`.
:return True wether a change happened.
"""
if on_update and not self.file_updated():
return
# check on name/remove/modification time
name = self.name
if not self.name and self.file and self.file.name:
name = os.path.basename(self.file.name)
name = os.path.splitext(name)[0]
name = name.replace("_", " ").strip()
is_removed = not self.file_exists()
mtime = self.get_mtime()
changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name
self.name, self.is_removed, self.mtime = name, is_removed, mtime
# read metadata
if changed and not self.is_removed:
metadata = self.read_metadata()
metadata and self.__dict__.update(metadata)
return changed
def read_metadata(self):
return {}
def save(self, sync=True, *args, **kwargs):
if sync and self.file_exists():
self.sync_fs(on_update=True)
super().save(*args, **kwargs)

View File

@ -183,10 +183,14 @@ class BasePage(Renderable, models.Model):
headline[-1] += suffix headline[-1] += suffix
return mark_safe(" ".join(headline)) return mark_safe(" ".join(headline))
_url_re = re.compile("(https?://[^\s\n]+)") _url_re = re.compile(
"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
)
@cached_property @cached_property
def display_content(self): def display_content(self):
if "<p>" in self.content:
return self.content
content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content) content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content)
return content.replace("\n\n", "\n").replace("\n", "<br>") return content.replace("\n\n", "\n").replace("\n", "<br>")

View File

@ -91,12 +91,12 @@ def schedule_post_save(sender, instance, created, *args, **kwargs):
def schedule_pre_delete(sender, instance, *args, **kwargs): def schedule_pre_delete(sender, instance, *args, **kwargs):
"""Delete later corresponding diffusion to a changed schedule.""" """Delete later corresponding diffusion to a changed schedule."""
Diffusion.objects.filter(schedule=instance).after(tz.now()).delete() Diffusion.objects.filter(schedule=instance).after(tz.now()).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete() Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
@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, sound__isnull=True).delete() Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
@receiver(signals.post_delete, sender=Sound) @receiver(signals.post_delete, sender=Sound)

View File

@ -1,240 +1,187 @@
import logging from datetime import date
import os import os
import re
from django.conf import settings as conf from django.conf import settings as conf
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from aircox import utils
from aircox.conf import settings from aircox.conf import settings
from .episode import Episode
from .program import Program from .program import Program
from .file import File, FileQuerySet
logger = logging.getLogger("aircox")
__all__ = ("Sound", "SoundQuerySet") __all__ = ("Sound", "SoundQuerySet")
class SoundQuerySet(models.QuerySet): class SoundQuerySet(FileQuerySet):
def station(self, station=None, id=None):
id = station.pk if id is None else id
return self.filter(program__station__id=id)
def episode(self, episode=None, id=None):
id = episode.pk if id is None else id
return self.filter(episode__id=id)
def diffusion(self, diffusion=None, id=None):
id = diffusion.pk if id is None else id
return self.filter(episode__diffusion__id=id)
def available(self):
return self.exclude(is_removed=False)
def public(self):
"""Return sounds available as podcasts."""
return self.filter(is_public=True)
def downloadable(self): def downloadable(self):
"""Return sounds available as podcasts.""" """Return sounds available as podcasts."""
return self.filter(is_downloadable=True) return self.filter(is_downloadable=True)
def archive(self): def broadcast(self):
"""Return sounds that are archives.""" """Return sounds that are archives."""
return self.filter(type=Sound.TYPE_ARCHIVE) return self.filter(broadcast=True)
def path(self, paths): def playlist(self, order_by="file"):
if isinstance(paths, str):
return self.filter(file=paths.replace(conf.MEDIA_ROOT + "/", ""))
return self.filter(file__in=(p.replace(conf.MEDIA_ROOT + "/", "") for p in paths))
def playlist(self, archive=True, order_by=True):
"""Return files absolute paths as a flat list (exclude sound without """Return files absolute paths as a flat list (exclude sound without
path). path)."""
If `order_by` is True, order by path.
"""
if archive:
self = self.archive()
if order_by: if order_by:
self = self.order_by("file") self = self.order_by(order_by)
return [ return [
os.path.join(conf.MEDIA_ROOT, file) os.path.join(conf.MEDIA_ROOT, file)
for file in self.filter(file__isnull=False).values_list("file", flat=True) for file in self.filter(file__isnull=False).values_list("file", flat=True)
] ]
def search(self, query):
return self.filter(
Q(name__icontains=query)
| Q(file__icontains=query)
| Q(program__title__icontains=query)
| Q(episode__title__icontains=query)
)
class Sound(File):
# TODO:
# - provide a default name based on program and episode
class Sound(models.Model):
"""A Sound is the representation of a sound file that can be either an
excerpt or a complete archive of the related diffusion."""
TYPE_OTHER = 0x00
TYPE_ARCHIVE = 0x01
TYPE_EXCERPT = 0x02
TYPE_CHOICES = (
(TYPE_OTHER, _("other")),
(TYPE_ARCHIVE, _("archive")),
(TYPE_EXCERPT, _("excerpt")),
)
name = models.CharField(_("name"), max_length=64)
program = models.ForeignKey(
Program,
models.CASCADE,
blank=True, # NOT NULL
verbose_name=_("program"),
help_text=_("program related to it"),
db_index=True,
)
episode = models.ForeignKey(
Episode,
models.SET_NULL,
blank=True,
null=True,
verbose_name=_("episode"),
db_index=True,
)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
position = models.PositiveSmallIntegerField(
_("order"),
default=0,
help_text=_("position in the playlist"),
)
is_removed = models.BooleanField(_("removed"), default=False, help_text=_("file has been removed"))
def _upload_to(self, filename):
subdir = settings.SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else settings.SOUND_EXCERPTS_SUBDIR
return os.path.join(self.program.path, subdir, filename)
file = models.FileField(
_("file"),
upload_to=_upload_to,
max_length=256,
db_index=True,
unique=True,
)
duration = models.TimeField( duration = models.TimeField(
_("duration"), _("duration"),
blank=True, blank=True,
null=True, null=True,
help_text=_("duration of the sound"), help_text=_("duration of the sound"),
) )
mtime = models.DateTimeField(
_("modification time"),
blank=True,
null=True,
help_text=_("last modification date and time"),
)
is_good_quality = models.BooleanField( is_good_quality = models.BooleanField(
_("good quality"), _("good quality"),
help_text=_("sound meets quality requirements"), help_text=_("sound meets quality requirements"),
blank=True, blank=True,
null=True, null=True,
) )
is_public = models.BooleanField(
_("public"),
help_text=_("sound is available as podcast"),
default=False,
)
is_downloadable = models.BooleanField( is_downloadable = models.BooleanField(
_("downloadable"), _("downloadable"),
help_text=_("sound can be downloaded by visitors (sound must be public)"), help_text=_("sound can be downloaded by visitors"),
default=False, default=False,
) )
broadcast = models.BooleanField(
_("Broadcast"),
default=False,
help_text=_("The sound is broadcasted on air"),
)
objects = SoundQuerySet.as_manager() objects = SoundQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("Sound") verbose_name = _("Sound file")
verbose_name_plural = _("Sounds") verbose_name_plural = _("Sound files")
@property _path_re = re.compile(
def url(self): "^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
return self.file and self.file.url "(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
"(_(?P<n>[0-9]+))?"
"_?[ -]*(?P<name>.*)$"
)
def __str__(self): @classmethod
return "/".join(self.file.path.split("/")[-3:]) def read_path(cls, path):
"""Parse path name returning dictionary of extracted info. It can
contain:
def save(self, check=True, *args, **kwargs): - `year`, `month`, `day`: diffusion date
if self.episode is not None and self.program is None: - `hour`, `minute`: diffusion time
self.program = self.episode.program - `n`: sound arbitrary number (used for sound ordering)
if check: - `name`: cleaned name extracted or file name (without extension)
self.check_on_file()
if not self.is_public:
self.is_downloadable = False
self.__check_name()
super().save(*args, **kwargs)
# TODO: rename get_file_mtime(self)
def get_mtime(self):
"""Get the last modification date from file."""
mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def file_exists(self):
"""Return true if the file still exists."""
return os.path.exists(self.file.path)
# TODO: rename to sync_fs()
def check_on_file(self):
"""Check sound file info again'st self, and update informations if
needed (do not save).
Return True if there was changes.
""" """
if not self.file_exists(): basename = os.path.basename(path)
if self.is_removed: basename = os.path.splitext(basename)[0]
return reg_match = cls._path_re.search(basename)
logger.debug("sound %s: has been removed", self.file.name) if reg_match:
self.is_removed = True info = reg_match.groupdict()
return True for k in ("year", "month", "day", "hour", "minute", "n"):
if info.get(k) is not None:
info[k] = int(info[k])
# not anymore removed name = info.get("name")
changed = False info["name"] = name and cls._as_name(name) or basename
else:
info = {"name": basename}
return info
if self.is_removed and self.program: @classmethod
changed = True def _as_name(cls, name):
self.type = ( name = name.replace("_", " ")
self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT return " ".join(r.capitalize() for r in name.split(" "))
def find_episode(self, path_info=None):
"""Base on self's file name, match date to an initial diffusion and
return corresponding episode or ``None``."""
pi = path_info or self.read_path(self.file.path)
if "year" not in pi:
return None
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
if pi.get("hour") is not None:
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
at = tz.make_aware(at)
else:
at = date(year, month, day)
diffusion = self.program.diffusion_set.at(at).first()
return diffusion and diffusion.episode or None
def find_playlist(self, meta=None):
"""Find a playlist file corresponding to the sound path, such as:
my_sound.ogg => my_sound.csv.
Use provided sound's metadata if any and no csv file has been
found.
"""
from aircox.controllers.playlist_import import PlaylistImport
from .track import Track
if self.track_set.count() > 1:
return
# import playlist
path_noext, ext = os.path.splitext(self.file.path)
path = path_noext + ".csv"
if os.path.exists(path):
PlaylistImport(path, sound=self).run()
# use metadata
elif meta and meta.tags:
title, artist, album, year = tuple(
t and ", ".join(t) for t in (meta.tags.get(k) for k in ("title", "artist", "album", "year"))
) )
title = title or path_noext
# check mtime -> reset quality if changed (assume file changed) info = "{} ({})".format(album, year) if album and year else album or year or ""
mtime = self.get_mtime() track = Track(
sound=self,
if self.mtime != mtime: position=int(meta.tags.get("tracknumber", 0)),
self.mtime = mtime title=title,
self.is_good_quality = None artist=artist or _("unknown"),
logger.debug( info=info,
"sound %s: m_time has changed. Reset quality info",
self.file.name,
) )
return True track.save()
def get_upload_dir(self):
if self.broadcast:
return settings.SOUND_BROADCASTS_SUBDIR
return settings.SOUND_EXCERPTS_SUBDIR
meta = None
"""Provided by read_metadata: Mutagen's metadata."""
def sync_fs(self, *args, find_playlist=False, **kwargs):
changed = super().sync_fs(*args, **kwargs)
if changed and not self.is_removed:
if not self.program:
self.program = Program.get_from_path(self.file.path)
changed = True
if find_playlist and self.meta:
not self.pk and self.save(sync=False)
self.find_playlist(self.meta)
return changed return changed
def __check_name(self): def read_metadata(self):
if not self.name and self.file and self.file.name: import mutagen
# FIXME: later, remove date?
name = os.path.basename(self.file.name)
name = os.path.splitext(name)[0]
self.name = name.replace("_", " ").strip()
def __init__(self, *args, **kwargs): meta = mutagen.File(self.file.path)
super().__init__(*args, **kwargs)
self.__check_name() metadata = {"duration": utils.seconds_to_time(meta.info.length), "meta": meta}
path_info = self.read_path(self.file.path)
if name := path_info.get("name"):
metadata["name"] = name
return metadata

View File

@ -1,11 +1,8 @@
import os
from django.db import models from django.db import models
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField from filer.fields.image import FilerImageField
from aircox.conf import settings
__all__ = ("Station", "StationQuerySet", "Port") __all__ = ("Station", "StationQuerySet", "Port")
@ -32,13 +29,6 @@ class Station(models.Model):
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
slug = models.SlugField(_("slug"), max_length=64, unique=True) slug = models.SlugField(_("slug"), max_length=64, unique=True)
# FIXME: remove - should be decided only by Streamer controller + settings
path = models.CharField(
_("path"),
help_text=_("path to the working directory"),
max_length=256,
blank=True,
)
default = models.BooleanField( default = models.BooleanField(
_("default station"), _("default station"),
default=False, default=False,
@ -96,12 +86,6 @@ class Station(models.Model):
return self.name return self.name
def save(self, make_sources=True, *args, **kwargs): def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(
settings.CONTROLLERS_WORKING_DIR,
self.slug.replace("-", "_"),
)
if self.default: if self.default:
qs = Station.objects.filter(default=True) qs = Station.objects.filter(default=True)
if self.pk is not None: if self.pk is not None:

View File

@ -1,12 +1,14 @@
from .admin import TrackSerializer, UserSettingsSerializer from .admin import TrackSerializer, UserSettingsSerializer
from .log import LogInfo, LogInfoSerializer from .log import LogInfo, LogInfoSerializer
from .sound import PodcastSerializer, SoundSerializer from .sound import SoundSerializer
from .episode import EpisodeSoundSerializer, EpisodeSerializer
__all__ = ( __all__ = (
"TrackSerializer",
"UserSettingsSerializer",
"LogInfo", "LogInfo",
"LogInfoSerializer", "LogInfoSerializer",
"EpisodeSoundSerializer",
"EpisodeSerializer",
"SoundSerializer", "SoundSerializer",
"PodcastSerializer", "TrackSerializer",
"UserSettingsSerializer",
) )

View File

@ -0,0 +1,36 @@
from rest_framework import serializers
from .. import models
from .sound import SoundSerializer
from .admin import TrackSerializer
class EpisodeSoundSerializer(serializers.ModelSerializer):
sound = SoundSerializer(read_only=True)
class Meta:
model = models.EpisodeSound
fields = [
"id",
"position",
"episode",
"broadcast",
"sound",
"sound_id",
]
class EpisodeSerializer(serializers.ModelSerializer):
playlist = EpisodeSoundSerializer(source="episodesound_set", many=True, read_only=True)
tracks = TrackSerializer(source="track_set", many=True, read_only=True)
class Meta:
model = models.Episode
fields = [
"id",
"title",
"content",
"pub_date",
"playlist",
"tracks",
]

View File

@ -1,23 +1,19 @@
from rest_framework import serializers from rest_framework import serializers
from ..models import Sound from .. import models
__all__ = ("SoundSerializer", "PodcastSerializer") __all__ = ("SoundSerializer",)
class SoundSerializer(serializers.ModelSerializer): class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False) file = serializers.FileField(use_url=False)
type_display = serializers.SerializerMethodField()
class Meta: class Meta:
model = Sound model = models.Sound
fields = [ fields = [
"pk", "id",
"name", "name",
"program", "program",
"episode",
"type",
"type_display",
"file", "file",
"duration", "duration",
"mtime", "mtime",
@ -26,24 +22,3 @@ class SoundSerializer(serializers.ModelSerializer):
"is_downloadable", "is_downloadable",
"url", "url",
] ]
def get_type_display(self, obj):
return obj.get_type_display()
class PodcastSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta:
model = Sound
fields = [
"pk",
"name",
"program",
"episode",
"type",
"duration",
"mtime",
"url",
"is_downloadable",
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -62,18 +62,10 @@ Usefull context:
{% for item, render in items %} {% for item, render in items %}
{{ render }} {{ render }}
{% endfor %} {% endfor %}
{% if user.is_staff %}
<a class="nav-item" href="{% url "admin:index" %}" target="new">
{% translate "Admin" %}
</a>
{% endif %}
{% if user.is_authenticated %}
<a class="nav-item" href="{% url "logout" %}" title="{% translate "Disconnect" %}"
aria-label="{% translate "Disconnect" %}">
<i class="fa fa-power-off"></i>
</a>
{% endif %}
{% endblock %} {% endblock %}
{% if user.is_authenticated %}
{% include "./dashboard/nav.html" %}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
</nav> </nav>

View File

@ -10,7 +10,7 @@ Context:
{% endcomment %} {% endcomment %}
{% load aircox %} {% load aircox %}
{% if field.is_hidden or hidden %} {% if field.widget.is_hidden or hidden %}
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}"> <input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
{% elif field|is_checkbox %} {% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}> <input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>

View File

@ -4,7 +4,10 @@ Base template for list editor based on formsets (tracklist_editor, playlist_edit
Context: Context:
- tag_id: id of parent component - tag_id: id of parent component
- tag: vue component tag (a-playlist-editor, etc.) - tag: vue component tag (a-playlist-editor, etc.)
- related_field: field name that target object
- object: related object
- formset: formset used to render the list editor - formset: formset used to render the list editor
- formset_data: formset data
{% endcomment %} {% endcomment %}
{% load aircox aircox_admin static i18n %} {% load aircox aircox_admin static i18n %}
@ -17,30 +20,14 @@ Context:
<{{ tag }} <{{ tag }}
{% block tag-attrs %} {% block tag-attrs %}
:labels="{% inline_labels %}" :form-data="{{ formset_data|json }}"
:labels="window.aircox.labels"
:init-data="{% formset_inline_data formset=formset %}" :init-data="{% formset_inline_data formset=formset %}"
:default-columns="[{% for f in fields.keys %}{% if f != "position" %}'{{ f }}',{% endif %}{% endfor %}]" :columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}" settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-" data-prefix="{{ formset.prefix }}-"
{% endblock %}> {% endblock %}>
{% block inner %} {% block inner %}
<template #top="{items}">
{% block top %}
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
:value="items.length || 0"/>
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
{% if no_initial_form_count %}
:value="items.length || 0"
{% else %}
value="{{ formset.initial_form_count }}"
{% endif %}
/>
<input type="hidden" name="{{ formset.prefix }}-MIN_NUM_FORMS"
value="{{ formset.min_num }}"/>
<input type="hidden" name="{{ formset.prefix }}-MAX_NUM_FORMS"
value="{{ formset.max_num }}"/>
{% endblock %}
</template>
<template #rows-header-head> <template #rows-header-head>
{% block rows-header-head %} {% block rows-header-head %}
<th style="max-width:2em" title="{{ fields.position.help_text }}" <th style="max-width:2em" title="{{ fields.position.help_text }}"
@ -51,42 +38,12 @@ Context:
</th> </th>
{% endblock %} {% endblock %}
</template> </template>
<template v-slot:row-head="{item,row}">
{% block row-head %}
<td>
[[ row+1 ]]
<input type="hidden"
:name="'{{ formset.prefix }}-' + row + '-position'"
:value="row"/>
<input t-if="item.data.id" type="hidden"
:name="'{{ formset.prefix }}-' + row + '-id'"
:value="item.data.id || item.id"/>
{% for name, field in fields.items %}
{% if name != 'position' and field.widget.is_hidden %}
<input type="hidden"
:name="'{{ formset.prefix }}-' + row + '-{{ name }}'"
v-model="item.data[attr]"/>
{% endif %}
{% endfor %}
</td>
{% endblock %}
</template>
{% for name, field in fields.items %} {% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %} {% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:row-{{ name }}="{item,cell,value,attr,emit}"> <template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}">
<div class="field"> {% block row-control %}
{% with full_name="'"|add:formset.prefix|add:"-' + cell.row + '-"|add:name|add:"'" %} {% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% block row-field %} {% endblock %}
<div class="control">
{% include "./v_form_field.html" with value="item.data."|add:name name=full_name %}
</div>
{% endblock %}
{% endwith %}
<p v-for="error in item.error(attr)" class="help is-danger">
[[ error ]] !
</p>
</div>
</template> </template>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -0,0 +1,27 @@
{% load i18n %}
<div class="dropdown is-hoverable is-right">
<div class="dropdown-trigger">
<button class="button square" aria-haspopup="true" aria-controls="dropdown-menu" type="button">
<span class="icon">
<i class="fa fa-user" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200">
<div class="dropdown-content">
{% block user-menu %}
{% endblock %}
{% if user.is_admin %}
{% block admin-menu %}
<a class="nav-item" href="{% url "admin:index" %}" target="new">
{% translate "Admin" %}
</a>
{% endblock %}
<hr class="dropdown-divider" />
{% endif %}
<a class="dropdown-item" href="{% url "logout" %}">
{% translate "Disconnect" %}
</a>
</div>
</div>
</div>

View File

@ -1,9 +1,13 @@
{% extends "./list_editor.html" %} {% extends "./list_editor.html" %}
{% comment %}
Context:
- object: episode
{% endcomment %}
{% block outer %} {% block outer %}
{% with no_initial_form_count=True %}
{% with tag_id="inline-sounds" %} {% with tag_id="inline-sounds" %}
{% with tag="a-sound-list-editor" %} {% with tag="a-sound-list-editor" %}
{% with related_field="episode" %}
{{ block.super }} {{ block.super }}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
@ -13,8 +17,9 @@
{% block tag-attrs %} {% block tag-attrs %}
{{ block.super }} {{ block.super }}
sound-list-url="{% url "api:sound-list" %}?program={{ object.pk }}&episode__isnull" sound-list-url="{% url "api:sound-list" %}?program={{ object.parent_id }}"
sound-upload-url="{% url "api:sound-list" %}" sound-upload-url="{% url "api:sound-list" %}"
sound-delete-url="{% url "api:sound-detail" pk=123 %}"
{% endblock %} {% endblock %}
{% block inner %} {% block inner %}
@ -23,19 +28,15 @@ sound-upload-url="{% url "api:sound-list" %}"
<template #upload-form> <template #upload-form>
{% for field in sound_form %} {% for field in sound_form %}
{% with field.name as name %} {% with field.name as name %}
{% with field.initial as value %} {% if name in "program" %}
{% with field.field as field %} {% include "./form_field.html" with value=field.initial field=field.field hidden=True %}
{% if name in "episode,program" %}
{% include "./form_field.html" with value=value hidden=True %}
{% elif name != "file" %} {% elif name != "file" %}
<div class="field is-horizontal"> <div class="field is-horizontal">
<label class="label mr-3">{{ field.label }}</label> <label class="label mr-3">{{ field.label }}</label>
{% include "./form_field.html" with value=value %} {% include "./form_field.html" with value=field.initial field=field.field %}
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</template> </template>
<template #row-delete="{cell}"> <template #row-delete="{cell}">

View File

@ -4,9 +4,11 @@
{% block outer %} {% block outer %}
{% with tag_id="inline-tracks" %} {% with tag_id="inline-tracks" %}
{% with tag="a-track-list-editor" %} {% with tag="a-track-list-editor" %}
{% with related_field="episode" %}
{{ block.super }} {{ block.super }}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% endwith %}
{% endblock %} {% endblock %}
{% block inner %} {% block inner %}
@ -14,12 +16,19 @@
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}
{% block row-field %} {% block row-control %}
{% if name == "tags" %}
<input type="text" class="input"
:name="inputName"
v-model="item.data[attr]"
@change="emit('change', cell.col)"
>
{% else %}
<a-autocomplete <a-autocomplete
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']" :input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}" url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}"
:name="'{{ formset.prefix }}-' + cell.row + '-{{ name }}'" :name="inputName"
v-model="item.data[attr]" v-model="item.data[attr]"
title="{{ name }}" @change="emit('change', cell.col)"/>
@change="emit('change', col)"/> {% endif %}
{% endblock %} {% endblock %}

View File

@ -9,7 +9,7 @@ Context:
{% endcomment %} {% endcomment %}
{% load aircox %} {% load aircox %}
{% if field.is_hidden or hidden %} {% if field.widget.is_hidden or hidden %}
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}"> <input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
{% elif field|is_checkbox %} {% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}"> <input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">

View File

@ -16,7 +16,7 @@
<a-playlist v-if="page" :set="podcasts" <a-playlist v-if="page" :set="podcasts"
name="{{ page.title }}" name="{{ page.title }}"
list-class="menu-list" item-class="menu-item" list-class="menu-list" item-class="menu-item"
:player="player" :actions="['play']" :player="player" :actions="['play', 'pin']"
@select="player.playItems('queue', $event.item)"> @select="player.playItems('queue', $event.item)">
</a-playlist> </a-playlist>
</section> </section>

View File

@ -6,12 +6,10 @@
<template v-slot="{podcasts,page}"> <template v-slot="{podcasts,page}">
{{ block.super }} {{ block.super }}
<hr/> <hr/>
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset %} {% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %}
<hr/> <hr/>
<section class="container"> <h3 class="title">{% translate "Podcasts" %}</h3>
<h3 class="title">{% translate "Podcasts" %}</h3> {% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %}
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset %}
</section>
</template> </template>
</a-episode> </a-episode>
{% endblock %} {% endblock %}

View File

@ -1,59 +1,56 @@
{% extends "./page_detail.html" %} {% extends "./page_detail.html" %}
{% load static i18n %} {% load static aircox_admin i18n %}
{% block assets %} {% block assets %}
{{ block.super }} {{ block.super }}
<script src="{% static "aircox/js/dashboard.js" %}"></script> <script src="{% static "aircox/js/dashboard.js" %}"></script>
{% endblock %} {% endblock %}
{% block init-scripts %}
aircox.labels = {% inline_labels %}
{{ block.super }}
{% endblock %}
{% block header-cover %} {% block header-cover %}
<div class="flex-column"> <div class="flex-column">
<img src="{{ cover }}" ref="cover" class="cover"> <img src="{{ cover }}" ref="cover" class="cover">
<button type="button" class="button" @click="$refs['cover-modal'].open()"> <button type="button" class="button" @click="$refs['cover-select'].open()">
{% translate "Change cover" %} {% translate "Change cover" %}
</button> </button>
</div> </div>
{% endblock %} {% endblock %}
{% block content-container %} {% block content-container %}
<a-modal ref="cover-modal" title="{% translate "Select an image" %}"> <a-select-file ref="cover-select"
<template #default> :labels="window.aircox.labels"
<a-select-file list-url="{% url "api:image-list" %}" upload-url="{% url "api:image-list" %}" list-url="{% url "api:image-list" %}"
list-class="grid-4" upload-url="{% url "api:image-list" %}"
prev-label="{% translate "Show previous" %}" delete-url="{% url "api:image-detail" pk=123 %}"
next-label="{% translate "Show next" %}" title="{% translate "Select an image" %}" list-class="grid-4"
ref="cover-select" @select="(event) => fileSelected('cover-select', 'cover-input', $refs.cover)"
> >
<template #upload-preview="{upload}"> <template #upload-preview="{upload}">
<img :src="upload.fileURL" class="upload-preview blink"/> <img :src="upload.fileURL" class="upload-preview blink"/>
</template>
<template #default="{item, load, lastUrl}">
<div class="flex-column is-fullheight" v-if="item">
<figure class="flex-grow-1">
<img :src="item.file"/>
</figure>
<div>
<label class="label small">[[ item.name || item.original_filename ]]</label>
<a-action-button
class="has-text-danger small float-right"
icon="fa fa-trash"
confirm="{% translate "Are you sure you want to remove this item from server?" %}"
method="DELETE"
:url="'{% url "api:image-detail" pk="123" %}'.replace('123', item.id)"
@done="load(lastUrl)">
</a-action-button>
</div>
</div>
</template>
</a-select-file>
</template> </template>
<template #footer> <template #default="{item, load, lastUrl}">
<button type="button" class="button align-right" <div class="flex-column is-fullheight" v-if="item">
@click="(event) => fileSelected('cover-select', 'cover', 'cover-input', 'cover-modal')"> <figure class="flex-grow-1">
{% translate "Select" %} <img :src="item.file"/>
</button> </figure>
<div>
<label class="label small">[[ item.name || item.original_filename ]]</label>
<a-action-button
class="has-text-danger small float-right"
icon="fa fa-trash"
confirm="{% translate "Are you sure you want to remove this item from server?" %}"
method="DELETE"
:url="'{% url "api:image-detail" pk="123" %}'.replace('123', item.id)"
@done="load(lastUrl)">
</a-action-button>
</div>
</div>
</template> </template>
</a-modal> </a-select-file>
<section class="container"> <section class="container">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
@ -67,12 +64,12 @@
<label class="label">{{ field.label }}</label> <label class="label">{{ field.label }}</label>
<div class="control clear-unset"> <div class="control clear-unset">
{% if field.name == "pub_date" %} {% if field.name == "pub_date" %}
<input type="datetime-local" name="{{ field.name }}" <input type="datetime-local" class="input" name="{{ field.name }}"
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/> value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
{% elif field.name == "content" %} {% elif field.name == "content" %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea> <textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
{% else %} {% else %}
{{ field }} {% include "./dashboard/form_field.html" with field=field.field name=field.name value=field.initial %}
{% endif %} {% endif %}
</div> </div>
<p class="help">{{ field.help_text }}</p> <p class="help">{{ field.help_text }}</p>

View File

@ -3,6 +3,7 @@ import json
from django import template from django import template
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.safestring import mark_safe
from aircox.serializers.admin import UserSettingsSerializer from aircox.serializers.admin import UserSettingsSerializer
@ -25,6 +26,7 @@ def do_formset_inline_data(context, formset):
- ``items``: list of items. Extra keys: - ``items``: list of items. Extra keys:
- ``__error__``: dict of form fields errors - ``__error__``: dict of form fields errors
- ``settings``: user's settings - ``settings``: user's settings
- ``fields``: dict of field name and label
""" """
# --- get fields labels # --- get fields labels
@ -43,6 +45,9 @@ def do_formset_inline_data(context, formset):
# hack for sound list # hack for sound list
if duration := item.get("duration"): if duration := item.get("duration"):
item["duration"] = duration.strftime("%H:%M") item["duration"] = duration.strftime("%H:%M")
if sound := getattr(form.instance, "sound"):
item["name"] = sound.name
fields["name"] = str(_("Sound")).capitalize()
# hack for playlist editor # hack for playlist editor
tags = item.get("tags") tags = item.get("tags")
@ -62,12 +67,20 @@ inline_labels_ = {
# list editor # list editor
"add_item": _("Add an item"), "add_item": _("Add an item"),
"remove_item": _("Remove"), "remove_item": _("Remove"),
"settings": _("Settings"),
"save_settings": _("Save Settings"), "save_settings": _("Save Settings"),
"discard_changes": _("Discard changes"), "discard_changes": _("Discard changes"),
"select_file": _("Select a file"),
"submit": _("Submit"), "submit": _("Submit"),
"delete": _("Delete"), "delete": _("Delete"),
# select file
"upload": _("Upload"),
"list": _("List"),
"confirm_delete": _("Are you sure to remove this element from the server?"),
"show_next": _("Show next"),
"show_previous": _("Show previous"),
"select_file": _("Select a file"),
# track list # track list
"text": _("Text"),
"columns": _("Columns"), "columns": _("Columns"),
"timestamp": _("Timestamp"), "timestamp": _("Timestamp"),
# sound list # sound list
@ -78,4 +91,4 @@ inline_labels_ = {
@register.simple_tag(name="inline_labels") @register.simple_tag(name="inline_labels")
def do_inline_labels(): def do_inline_labels():
"""Return labels for columns in playlist editor as dict.""" """Return labels for columns in playlist editor as dict."""
return json.dumps({k: str(v) for k, v in inline_labels_.items()}) return mark_safe(json.dumps({k: str(v) for k, v in inline_labels_.items()}))

View File

@ -131,25 +131,32 @@ def episode(episodes):
@pytest.fixture @pytest.fixture
def podcasts(episodes): def sound(program):
items = [] return baker.make(models.Sound, file="tmp/test.wav", program=program)
for episode in episodes:
sounds = baker.prepare(
models.Sound,
episode=episode,
program=episode.program,
is_public=True,
_quantity=2,
)
for i, sound in enumerate(sounds):
sound.file = f"test_sound_{episode.pk}_{i}.mp3"
items += sounds
return items
@pytest.fixture @pytest.fixture
def sound(program): def sounds(program):
return baker.make(models.Sound, file="tmp/test.wav", program=program) objs = [
models.Sound(program=program, file=f"tmp/test-{i}.wav", broadcast=(i == 0), is_downloadable=(i == 1))
for i in range(0, 3)
]
models.Sound.objects.bulk_create(objs)
return objs
@pytest.fixture
def podcasts(episode, sounds):
objs = [
models.EpisodeSound(
episode=episode,
sound=sound,
broadcast=True,
)
for sound in sounds
]
models.EpisodeSound.objects.bulk_create(objs)
return objs
@pytest.fixture @pytest.fixture

View File

@ -1,14 +1,12 @@
import pytest import pytest
from datetime import timedelta
from django.conf import settings as conf from django.conf import settings as conf
from django.utils import timezone as tz
from aircox import models
from aircox.controllers.sound_file import SoundFile from aircox.controllers.sound_file import SoundFile
# FIXME: use from tests.models.sound
@pytest.fixture @pytest.fixture
def path_infos(): def path_infos():
return { return {
@ -27,6 +25,7 @@ def path_infos():
"day": 2, "day": 2,
"hour": 10, "hour": 10,
"minute": 13, "minute": 13,
"n": None,
"name": "Sample 2", "name": "Sample 2",
}, },
"test/20220103_1_sample_3.mp3": { "test/20220103_1_sample_3.mp3": {
@ -56,42 +55,25 @@ def sound_files(path_infos):
return {k: r for k, r in ((path, SoundFile(conf.MEDIA_ROOT + "/" + path)) for path in path_infos.keys())} return {k: r for k, r in ((path, SoundFile(conf.MEDIA_ROOT + "/" + path)) for path in path_infos.keys())}
@pytest.fixture
def sound_file(sound_files):
return next(sound_files.items())
def test_sound_path(sound_files): def test_sound_path(sound_files):
for path, sound_file in sound_files.items(): for path, sound_file in sound_files.items():
assert path == sound_file.sound_path assert path == sound_file.sound_path
def test_read_path(path_infos, sound_files): class TestSoundFile:
for path, sound_file in sound_files.items(): def sound_path(self, sound_file):
expected = path_infos[path] assert sound_file[0] == sound_file[1].sound_path
result = sound_file.read_path(path)
# remove None values
result = {k: v for k, v in result.items() if v is not None}
assert expected == result, "path: {}".format(path)
def sync(self):
raise NotImplementedError("test is not implemented")
def _setup_diff(program, info): def create_episode_sound(self):
episode = models.Episode(program=program, title="test-episode") raise NotImplementedError("test is not implemented")
at = tz.datetime(**{k: info[k] for k in ("year", "month", "day", "hour", "minute") if info.get(k)})
at = tz.make_aware(at)
diff = models.Diffusion(episode=episode, start=at, end=at + timedelta(hours=1))
episode.save()
diff.save()
return diff
def _on_delete(self):
@pytest.mark.django_db(transaction=True) raise NotImplementedError("test is not implemented")
def test_find_episode(sound_files):
station = models.Station(name="test-station")
program = models.Program(station=station, title="test")
station.save()
program.save()
for path, sound_file in sound_files.items():
infos = sound_file.read_path(path)
diff = _setup_diff(program, infos)
sound = models.Sound(program=diff.program, file=path)
result = sound_file.find_episode(sound, infos)
assert diff.episode == result
# TODO: find_playlist, sync

View File

@ -223,22 +223,19 @@ class TestSoundMonitor:
[ [
(("scan all programs...",), {}), (("scan all programs...",), {}),
] ]
+ [ + [((f"#{program.id} {program.title}",), {}) for program in programs]
((f"#{program.id} {program.title}",), {})
for program in programs
]
) )
assert dirs == [program.abspath for program in programs] assert dirs == [program.abspath for program in programs]
traces = tuple( traces = tuple(
[ [
[ [
( (
(program, settings.SOUND_ARCHIVES_SUBDIR), (program, settings.SOUND_BROADCASTS_SUBDIR),
{"logger": logger, "type": Sound.TYPE_ARCHIVE}, {"logger": logger, "broadcast": True},
), ),
( (
(program, settings.SOUND_EXCERPTS_SUBDIR), (program, settings.SOUND_EXCERPTS_SUBDIR),
{"logger": logger, "type": Sound.TYPE_EXCERPT}, {"logger": logger, "broadcast": False},
), ),
] ]
for program in programs for program in programs
@ -247,6 +244,7 @@ class TestSoundMonitor:
traces_flat = tuple([item for sublist in traces for item in sublist]) traces_flat = tuple([item for sublist in traces for item in sublist])
assert interface._traces("scan_for_program") == traces_flat assert interface._traces("scan_for_program") == traces_flat
# TODO / FIXME
def broken_test_monitor(self, monitor, monitor_interfaces, logger): def broken_test_monitor(self, monitor, monitor_interfaces, logger):
def sleep(*args, **kwargs): def sleep(*args, **kwargs):
monitor.stop() monitor.stop()
@ -260,6 +258,7 @@ class TestSoundMonitor:
assert observer assert observer
schedules = observer._traces("schedule") schedules = observer._traces("schedule")
for (handler, *_), kwargs in schedules: for (handler, *_), kwargs in schedules:
breakpoint()
assert isinstance(handler, sound_monitor.MonitorHandler) assert isinstance(handler, sound_monitor.MonitorHandler)
assert isinstance(handler.pool, futures.ThreadPoolExecutor) assert isinstance(handler.pool, futures.ThreadPoolExecutor)
assert (handler.subdir, handler.type) in ( assert (handler.subdir, handler.type) in (

View File

@ -0,0 +1,122 @@
from datetime import timedelta
import os
import pytest
from django.conf import settings
from django.utils import timezone as tz
from aircox import models
@pytest.fixture
def path_infos():
return {
"test/20220101_10h13_1_sample_1.mp3": {
"year": 2022,
"month": 1,
"day": 1,
"hour": 10,
"minute": 13,
"n": 1,
"name": "Sample 1",
},
"test/20220102_10h13_sample_2.mp3": {
"year": 2022,
"month": 1,
"day": 2,
"hour": 10,
"minute": 13,
"n": None,
"name": "Sample 2",
},
"test/20220103_1_sample_3.mp3": {
"year": 2022,
"month": 1,
"day": 3,
"hour": None,
"minute": None,
"n": 1,
"name": "Sample 3",
},
"test/20220104_sample_4.mp3": {
"year": 2022,
"month": 1,
"day": 4,
"hour": None,
"minute": None,
"n": None,
"name": "Sample 4",
},
"test/20220105.mp3": {
"year": 2022,
"month": 1,
"day": 5,
"hour": None,
"minute": None,
"n": None,
"name": "20220105",
},
}
class TestSoundQuerySet:
@pytest.mark.django_db
def test_downloadable(self, sounds):
query = models.Sound.objects.downloadable().values_list("is_downloadable", flat=True)
assert set(query) == {True}
@pytest.mark.django_db
def test_broadcast(self, sounds):
query = models.Sound.objects.broadcast().values_list("broadcast", flat=True)
assert set(query) == {True}
@pytest.mark.django_db
def test_playlist(self, sounds):
expected = [os.path.join(settings.MEDIA_ROOT, s.file.path) for s in sounds]
assert models.Sound.objects.all().playlist() == expected
class TestSound:
@pytest.mark.django_db
def test_read_path(self, path_infos):
for path, expected in path_infos.items():
result = models.Sound.read_path(path)
assert expected == result
@pytest.mark.django_db
def test__as_name(self):
name = "some_1_file"
assert models.Sound._as_name(name) == "Some 1 File"
def _setup_diff(self, program, info):
episode = models.Episode(program=program, title="test-episode")
at = tz.datetime(**{k: info[k] for k in ("year", "month", "day", "hour", "minute") if info.get(k)})
at = tz.make_aware(at)
diff = models.Diffusion(episode=episode, start=at, end=at + timedelta(hours=1))
episode.save()
diff.save()
return diff
@pytest.mark.django_db(transaction=True)
def test_find_episode(self, program, path_infos):
for path, infos in path_infos.items():
diff = self._setup_diff(program, infos)
sound = models.Sound(program=diff.program, file=path)
result = sound.find_episode(infos)
assert diff.episode == result
@pytest.mark.django_db
def test_find_playlist(self):
raise NotImplementedError("test is not implemented")
@pytest.mark.django_db
def test_get_upload_dir(self):
raise NotImplementedError("test is not implemented")
@pytest.mark.django_db
def test_sync_fs(self):
raise NotImplementedError("test is not implemented")
@pytest.mark.django_db
def test_read_metadata(self):
raise NotImplementedError("test is not implemented")

View File

@ -1,10 +1,8 @@
# FIXME: this should be cleaner
from itertools import chain from itertools import chain
import json import json
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.core.files.uploadedfile import SimpleUploadedFile
from aircox.models import Program
@pytest.mark.django_db() @pytest.mark.django_db()
@ -22,20 +20,6 @@ def test_edit_program(user, client, program):
assert b"foobar" in response.content assert b"foobar" in response.content
@pytest.mark.django_db()
def test_add_cover(user, client, program, png_content):
assert program.cover is None
user.groups.add(program.editors)
client.force_login(user)
cover = SimpleUploadedFile("cover1.png", png_content, content_type="image/png")
r = client.post(
reverse("program-edit", kwargs={"pk": program.pk}), {"content": "foobar", "new_cover": cover}, follow=True
)
assert r.status_code == 200
p = Program.objects.get(pk=program.pk)
assert "cover1.png" in p.cover.url
@pytest.mark.django_db() @pytest.mark.django_db()
def test_edit_tracklist(user, client, program, episode, tracks): def test_edit_tracklist(user, client, program, episode, tracks):
user.groups.add(program.editors) user.groups.add(program.editors)

View File

@ -11,6 +11,9 @@ class FakeView:
def ___init__(self): def ___init__(self):
self.kwargs = {} self.kwargs = {}
def dispatch(self, *args, **kwargs):
pass
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
pass pass

View File

@ -43,7 +43,6 @@ class TestBaseView:
"view": base_view, "view": base_view,
"station": station, "station": station,
"page": None, # get_page() returns None "page": None, # get_page() returns None
"audio_streams": station.streams,
"model": base_view.model, "model": base_view.model,
} }

View File

@ -40,7 +40,7 @@ def parent_mixin():
@pytest.fixture @pytest.fixture
def attach_mixin(): def attach_mixin():
class Mixin(mixins.AttachedToMixin, FakeView): class Mixin(mixins.AttachedToMixin, FakeView):
attach_to_value = models.StaticPage.ATTACH_TO_HOME attach_to_value = models.StaticPage.Target.HOME
return Mixin() return Mixin()
@ -105,10 +105,10 @@ class TestParentMixin:
def test_get_parent_not_parent_url_kwargs(self, parent_mixin): def test_get_parent_not_parent_url_kwargs(self, parent_mixin):
assert parent_mixin.get_parent(self.req) is None assert parent_mixin.get_parent(self.req) is None
def test_get_calls_parent(self, parent_mixin): def test_dispatch_calls_parent(self, parent_mixin):
parent = "parent object" parent = "parent object"
parent_mixin.get_parent = lambda *_, **kw: parent parent_mixin.get_parent = lambda *_, **kw: parent
parent_mixin.get(self.req) parent_mixin.dispatch(self.req)
assert parent_mixin.parent == parent assert parent_mixin.parent == parent
@pytest.mark.django_db @pytest.mark.django_db
@ -120,7 +120,7 @@ class TestParentMixin:
assert set(query) == episodes_id assert set(query) == episodes_id
def test_get_context_data_with_parent(self, parent_mixin): def test_get_context_data_with_parent(self, parent_mixin):
parent_mixin.parent = Interface(cover="parent-cover") parent_mixin.parent = Interface(cover=Interface(url="parent-cover"))
context = parent_mixin.get_context_data() context = parent_mixin.get_context_data()
assert context["cover"] == "parent-cover" assert context["cover"] == "parent-cover"

View File

@ -19,7 +19,7 @@ class AdminMixin(LoginRequiredMixin, UserPassesTestMixin):
return self.request.station return self.request.station
def test_func(self): def test_func(self):
return self.request.user.is_staff return self.request.user.is_admin
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update(admin.site.each_context(self.request)) kwargs.update(admin.site.each_context(self.request))

View File

@ -7,7 +7,6 @@ __all__ = ("BaseView", "BaseAPIView")
class BaseView(TemplateResponseMixin, ContextMixin): class BaseView(TemplateResponseMixin, ContextMixin):
header_template_name = "aircox/widgets/header.html"
related_count = 4 related_count = 4
related_carousel_count = 8 related_carousel_count = 8
@ -50,8 +49,8 @@ class BaseView(TemplateResponseMixin, ContextMixin):
return None return None
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.setdefault("station", self.station)
kwargs.setdefault("page", self.get_page()) kwargs.setdefault("page", self.get_page())
kwargs.setdefault("header_template_name", self.header_template_name)
if "model" not in kwargs: if "model" not in kwargs:
model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object) model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object)

View File

@ -1,9 +1,10 @@
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse from django.urls import reverse
from aircox.models import Episode, Program, StaticPage, Sound, Track from aircox.models import Episode, Program, StaticPage, Track
from aircox import forms from aircox import forms, filters
from ..filters import EpisodeFilters
from .mixins import VueFormDataMixin
from .page import PageListView from .page import PageListView
from .program import ProgramPageDetailView, BaseProgramMixin from .program import ProgramPageDetailView, BaseProgramMixin
from .page import PageUpdateView from .page import PageUpdateView
@ -36,7 +37,7 @@ class EpisodeDetailView(ProgramPageDetailView):
class EpisodeListView(PageListView): class EpisodeListView(PageListView):
model = Episode model = Episode
filterset_class = EpisodeFilters filterset_class = filters.EpisodeFilters
parent_model = Program parent_model = Program
attach_to_value = StaticPage.Target.EPISODES attach_to_value = StaticPage.Target.EPISODES
@ -46,7 +47,7 @@ class PodcastListView(EpisodeListView):
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date") queryset = Episode.objects.published().with_podcasts().order_by("-pub_date")
class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView): class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, BaseProgramMixin, PageUpdateView):
model = Episode model = Episode
form_class = forms.EpisodeForm form_class = forms.EpisodeForm
template_name = "aircox/episode_form.html" template_name = "aircox/episode_form.html"
@ -63,38 +64,39 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
{ {
"prefix": "tracks", "prefix": "tracks",
"queryset": self.get_tracklist_queryset(episode), "queryset": self.get_tracklist_queryset(episode),
"initial": { "initial": [
"episode": episode.id, {
}, "episode": episode.id,
}
],
} }
) )
return forms.TrackFormSet(**kwargs) return forms.TrackFormSet(**kwargs)
def get_soundlist_queryset(self, episode): def get_soundlist_queryset(self, episode):
return episode.sound_set.all().order_by("position") return episode.episodesound_set.all().select_related("sound").order_by("position")
def get_soundlist_formset(self, episode, **kwargs): def get_soundlist_formset(self, episode, **kwargs):
kwargs.update( kwargs.update(
{ {
"prefix": "sounds", "prefix": "sounds",
"queryset": self.get_soundlist_queryset(episode), "queryset": self.get_soundlist_queryset(episode),
"initial": { "initial": [
"program": episode.parent_id, {
"episode": episode.id, "episode": episode.id,
}, }
],
} }
) )
return forms.SoundFormSet(**kwargs) return forms.EpisodeSoundFormSet(**kwargs)
def get_sound_form(self, episode, **kwargs): def get_sound_form(self, episode, **kwargs):
kwargs.update( kwargs.update(
{ {
"initial": { "initial": {
"program": episode.parent_id, "program": episode.parent_id,
"episode": episode.pk,
"name": episode.title, "name": episode.title,
"is_public": True, "is_public": True,
"type": Sound.TYPE_ARCHIVE,
}, },
} }
) )
@ -109,6 +111,10 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
for key, func in forms: for key, func in forms:
if key not in kwargs: if key not in kwargs:
kwargs[key] = func(self.object) kwargs[key] = func(self.object)
for key in ("soundlist_formset", "tracklist_formset"):
formset = kwargs[key]
kwargs[f"{key}_data"] = self.get_formset_data(formset, {"episode": self.object.id})
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View File

@ -108,3 +108,37 @@ class FiltersMixin:
params = self.request.GET.copy() params = self.request.GET.copy()
kwargs["get_params"] = params.pop("page", True) and params kwargs["get_params"] = params.pop("page", True) and params
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class VueFormDataMixin:
"""Provide form information as data to be used with vue components."""
# Note: values corresponds to AFormSet expected one
def get_form_field_data(self, form, values=None):
"""Return form fields as data."""
model = form.Meta.model
fields = ((name, field, model._meta.get_field(name)) for name, field in form.base_fields.items())
return [
{
"name": name,
"label": str(m_field.verbose_name).capitalize(),
"help": str(m_field.help_text).capitalize(),
"hidden": field.widget.is_hidden,
"value": values and values.get(name),
}
for name, field, m_field in fields
]
def get_formset_data(self, formset, field_values=None, **kwargs):
"""Return formset as data object."""
return {
"prefix": formset.prefix,
"management": {
"initial_forms": formset.initial_form_count(),
"min_num_forms": formset.min_num,
"max_num_forms": formset.max_num,
},
"fields": self.get_form_field_data(formset.form, field_values),
**kwargs,
}

View File

@ -5,8 +5,7 @@ from rest_framework.response import Response
from filer.models.imagemodels import Image from filer.models.imagemodels import Image
from . import models, forms, filters from . import models, forms, filters, serializers
from .serializers import SoundSerializer, admin
from .views import BaseAPIView from .views import BaseAPIView
__all__ = ( __all__ = (
@ -19,7 +18,8 @@ __all__ = (
class ImageViewSet(viewsets.ModelViewSet): class ImageViewSet(viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,) parsers = (parsers.MultiPartParser,)
serializer_class = admin.ImageSerializer permissions = (permissions.IsAuthenticatedOrReadOnly,)
serializer_class = serializers.admin.ImageSerializer
queryset = Image.objects.all().order_by("-uploaded_at") queryset = Image.objects.all().order_by("-uploaded_at")
filter_backends = (drf_filters.DjangoFilterBackend,) filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.ImageFilterSet filterset_class = filters.ImageFilterSet
@ -37,8 +37,8 @@ class ImageViewSet(viewsets.ModelViewSet):
class SoundViewSet(BaseAPIView, viewsets.ModelViewSet): class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,) parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,) permissions = (permissions.IsAuthenticatedOrReadOnly,)
serializer_class = SoundSerializer serializer_class = serializers.SoundSerializer
queryset = models.Sound.objects.available().order_by("-pk") queryset = models.Sound.objects.order_by("-pk")
filter_backends = (drf_filters.DjangoFilterBackend,) filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.SoundFilterSet filterset_class = filters.SoundFilterSet
@ -48,11 +48,17 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
# -> file is saved to fs after object is saved to db # -> file is saved to fs after object is saved to db
obj.save() obj.save()
def get_queryset(self):
query = super().get_queryset()
if not self.request.user.is_authenticated:
return query.available()
return query
class TrackROViewSet(viewsets.ReadOnlyModelViewSet): class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
"""Track viewset used for auto completion.""" """Track viewset used for auto completion."""
serializer_class = admin.TrackSerializer serializer_class = serializers.admin.TrackSerializer
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
filter_backends = (drf_filters.DjangoFilterBackend,) filter_backends = (drf_filters.DjangoFilterBackend,)
filterset_class = filters.TrackFilterSet filterset_class = filters.TrackFilterSet
@ -75,7 +81,7 @@ class UserSettingsViewSet(viewsets.ViewSet):
Allow only to create and edit user's own settings. Allow only to create and edit user's own settings.
""" """
serializer_class = admin.UserSettingsSerializer serializer_class = serializers.admin.UserSettingsSerializer
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
def get_serializer(self, instance=None, **kwargs): def get_serializer(self, instance=None, **kwargs):

View File

@ -77,9 +77,12 @@ class Metadata:
air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S") air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S")
return local_tz.localize(air_time) return local_tz.localize(air_time)
def validate(self, data): def validate(self, data, as_dict=False):
"""Validate provided data and set as attribute (must already be """Validate provided data and set as attribute (must already be
declared)""" declared)"""
if as_dict and isinstance(data, list):
data = {v[0]: v[1] for v in data}
for key, value in data.items(): for key, value in data.items():
if hasattr(self, key) and not callable(getattr(self, key)): if hasattr(self, key) and not callable(getattr(self, key)):
setattr(self, key, value) setattr(self, key, value)

View File

@ -133,8 +133,10 @@ class Monitor:
# get sound # get sound
diff = None diff = None
sound = Sound.objects.path(air_uri).first() sound = Sound.objects.path(air_uri).first()
if sound and sound.episode_id is not None: if sound:
diff = Diffusion.objects.episode(id=sound.episode_id).on_air().now(air_time).first() ids = sound.episodesound_set.values_list("episode_id", flat=True)
if ids:
diff = Diffusion.objects.filter(episode_id__in=ids).on_air().now(air_time).first()
# log sound on air # log sound on air
return self.log( return self.log(
@ -198,7 +200,7 @@ class Monitor:
Diffusion.objects.station(self.station) Diffusion.objects.station(self.station)
.on_air() .on_air()
.now(now) .now(now)
.filter(episode__sound__type=Sound.TYPE_ARCHIVE) .filter(episode__episodesound__broadcast=True)
.first() .first()
) )
# Can't use delay: diffusion may start later than its assigned start. # Can't use delay: diffusion may start later than its assigned start.
@ -227,7 +229,7 @@ class Monitor:
return log return log
def start_diff(self, source, diff): def start_diff(self, source, diff):
playlist = Sound.objects.episode(id=diff.episode_id).playlist() playlist = diff.episode.episodesound_set.all().broadcast().playlist()
source.push(*playlist) source.push(*playlist)
self.log( self.log(
type=Log.TYPE_START, type=Log.TYPE_START,

View File

@ -43,9 +43,9 @@ class Source(Metadata):
except ValueError: except ValueError:
self.remaining = None self.remaining = None
data = self.controller.send(self.id, ".get", parse=True) data = self.controller.send(f"var.get {self.id}_meta", parse_json=True)
if data: if data:
self.validate(data if data and isinstance(data, dict) else {}) self.validate(data if data and isinstance(data, (dict, list)) else {}, as_dict=True)
def skip(self): def skip(self):
"""Skip the current source sound.""" """Skip the current source sound."""
@ -80,7 +80,7 @@ class PlaylistSource(Source):
def get_sound_queryset(self): def get_sound_queryset(self):
"""Get playlist's sounds queryset.""" """Get playlist's sounds queryset."""
return self.program.sound_set.archive() return self.program.sound_set.broadcast()
def get_playlist(self): def get_playlist(self):
"""Get playlist from db.""" """Get playlist from db."""

View File

@ -8,8 +8,7 @@ import subprocess
import psutil import psutil
from django.template.loader import render_to_string from django.template.loader import render_to_string
from aircox.conf import settings from ..conf import settings
from ..connector import Connector from ..connector import Connector
from .sources import PlaylistSource, QueueSource from .sources import PlaylistSource, QueueSource
@ -46,8 +45,8 @@ class Streamer:
self.outputs = self.station.port_set.active().output() 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 = settings.get_dir(station, "station.liq")
self.connector = connector or Connector(os.path.join(station.path, "station.sock")) self.connector = connector or Connector(settings.get_dir(station, "station.sock"))
self.init_sources() self.init_sources()
@property @property
@ -98,7 +97,6 @@ class Streamer:
{ {
"station": self.station, "station": self.station,
"streamer": self, "streamer": self,
"settings": settings,
}, },
) )
data = re.sub("[\t ]+\n", "\n", data) data = re.sub("[\t ]+\n", "\n", data)

View File

@ -19,7 +19,7 @@ from aircox_streamer.controllers import Monitor, Streamer
# force using UTC # force using UTC
tz.activate(timezone.UTC) tz.activate(timezone.utc)
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -10,9 +10,9 @@ Base liquidsoap station configuration.
{% block functions %} {% block functions %}
{# Seek function #} {# Seek function #}
def seek(source, t) = def seek(s, t) =
t = float_of_string(default=0.,t) t = float_of_string(default=0.,t)
ret = source.seek(source,t) ret = source.seek(s,t)
log("seek #{ret} seconds.") log("seek #{ret} seconds.")
"#{ret}" "#{ret}"
end end
@ -30,6 +30,17 @@ def to_stream(live, stream)
add(normalize=false, [live,stream]) add(normalize=false, [live,stream])
end end
{# Skip command #}
def add_skip_command(s) =
def skip(_) =
source.skip(s)
"Done!"
end
server.register(namespace="#{source.id(s)}",
usage="skip",
description="Skip the current song.",
"skip",skip)
end
{% comment %} {% comment %}
An interactive source is a source that: An interactive source is a source that:
@ -45,10 +56,14 @@ def interactive (id, s) =
server.register(namespace=id, server.register(namespace=id,
description="Get source's track remaining time", description="Get source's track remaining time",
usage="remaining", usage="remaining",
"remaining", fun (_) -> begin json_of(source.remaining(s)) end) "remaining", fun (_) -> begin json.stringify(source.remaining(s)) end)
s = store_metadata(id=id, size=1, s)
add_skip_command(s) add_skip_command(s)
{# metadata: create an interactive variable as "{id}_meta" #}
s_meta = interactive.string("#{id}_meta", "")
s = source.on_metadata(s, fun(meta) -> s_meta.set(json.stringify(meta)))
s s
end end
@ -66,9 +81,6 @@ end
set("server.socket", true) set("server.socket", true)
set("server.socket.path", "{{ streamer.socket_path }}") set("server.socket.path", "{{ streamer.socket_path }}")
set("log.file.path", "{{ station.path }}/liquidsoap.log") set("log.file.path", "{{ station.path }}/liquidsoap.log")
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
set("{{ key|safe }}", {{ value|safe }})
{% endfor %}
{% endblock %} {% endblock %}
{% block config_extras %} {% block config_extras %}

View File

@ -146,24 +146,28 @@ def episode(program):
def sound(program, episode): def sound(program, episode):
sound = models.Sound( sound = models.Sound(
program=program, program=program,
episode=episode,
name="sound", name="sound",
type=models.Sound.TYPE_ARCHIVE, broadcast=True,
position=0,
file="sound.mp3", file="sound.mp3",
) )
sound.save(check=False) sound.save(sync=False)
return sound return sound
@pytest.fixture
def episode_sound(episode, sound):
obj = models.EpisodeSound(episode=episode, sound=sound, position=0, broadcast=sound.broadcast)
obj.save()
return obj
@pytest.fixture @pytest.fixture
def sounds(program): def sounds(program):
items = [ items = [
models.Sound( models.Sound(
name=f"sound {i}", name=f"sound {i}",
program=program, program=program,
type=models.Sound.TYPE_ARCHIVE, broadcast=True,
position=i,
file=f"sound-{i}.mp3", file=f"sound-{i}.mp3",
) )
for i in range(0, 3) for i in range(0, 3)

View File

@ -20,7 +20,7 @@ def monitor(streamer):
@pytest.fixture @pytest.fixture
def diffusion(program, episode, sound): def diffusion(program, episode, episode_sound):
return baker.make( return baker.make(
models.Diffusion, models.Diffusion,
program=program, program=program,
@ -33,10 +33,10 @@ def diffusion(program, episode, sound):
@pytest.fixture @pytest.fixture
def source(monitor, streamer, sound, diffusion): def source(monitor, streamer, episode_sound, diffusion):
source = next(monitor.streamer.playlists) source = next(monitor.streamer.playlists)
source.uri = sound.file.path source.uri = episode_sound.sound.file.path
source.episode_id = sound.episode_id source.episode_id = episode_sound.episode_id
source.air_time = diffusion.start + tz.timedelta(seconds=10) source.air_time = diffusion.start + tz.timedelta(seconds=10)
return source return source
@ -185,7 +185,7 @@ class TestMonitor:
monitor.trace_tracks(log) monitor.trace_tracks(log)
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_handle_diffusions(self, monitor, streamer, diffusion, sound): def test_handle_diffusions(self, monitor, streamer, diffusion, episode_sound):
interface( interface(
monitor, monitor,
{ {

View File

@ -67,7 +67,7 @@ class TestPlaylistSource:
@pytest.mark.django_db @pytest.mark.django_db
def test_get_sound_queryset(self, playlist_source, sounds): def test_get_sound_queryset(self, playlist_source, sounds):
query = playlist_source.get_sound_queryset() query = playlist_source.get_sound_queryset()
assert all(r.program_id == playlist_source.program.pk and r.type == r.TYPE_ARCHIVE for r in query) assert all(r.program_id == playlist_source.program.pk and r.broadcast for r in query)
@pytest.mark.django_db @pytest.mark.django_db
def test_get_playlist(self, playlist_source, sounds): def test_get_playlist(self, playlist_source, sounds):

View File

@ -137,7 +137,7 @@ class QueueSourceViewSet(SourceViewSet):
model = controllers.QueueSource model = controllers.QueueSource
def get_sound_queryset(self, request): def get_sound_queryset(self, request):
return Sound.objects.station(request.station).archive() return Sound.objects.station(request.station).broadcast()
@action(detail=True, methods=["POST"]) @action(detail=True, methods=["POST"])
def push(self, request, pk): def push(self, request, pk):

View File

@ -1,5 +1,5 @@
<template> <template>
<component :is="tag" @click.capture.stop="call" type="button" :class="buttonClass"> <component :is="tag" @click.capture.stop="call" type="button" :class="[buttonClass, this.promise && 'blink' || '']">
<span v-if="promise && runIcon"> <span v-if="promise && runIcon">
<i :class="runIcon"></i> <i :class="runIcon"></i>
</span> </span>

View File

@ -94,9 +94,13 @@ export default {
this.inputValue = value this.inputValue = value
}, },
inputValue(value) { inputValue(value, old) {
if(value != this.inputValue && value != this.modelValue) if(value != old && value != this.modelValue) {
this.$emit('update:modelValue', value) this.$emit('update:modelValue', value)
this.$emit('change', {target: this.$refs.input})
}
if(this.selectedLabel != value)
this.selectedIndex = -1
}, },
}, },
@ -176,8 +180,11 @@ export default {
}, },
onBlur(event) { onBlur(event) {
var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex; if(!this.items.length)
if(index !== undefined) return
var index = event.relatedTarget && Math.parseInt(event.relatedTarget.dataset.autocompleteIndex);
if(index !== undefined && index !== null)
this.select(index, false, false) this.select(index, false, false)
this.cursor = -1; this.cursor = -1;
}, },

View File

@ -29,7 +29,7 @@
import {getCsrf} from "../model" import {getCsrf} from "../model"
export default { export default {
emit: ["fileChange", "load"], emit: ["fileChange", "load", "abort", "error"],
props: { props: {
url: { type: String }, url: { type: String },
@ -71,9 +71,9 @@ export default {
const req = new XMLHttpRequest() const req = new XMLHttpRequest()
req.open("POST", this.url) req.open("POST", this.url)
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e)) req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
req.addEventListener("load", (e) => this.onUploadDone(e)) req.addEventListener("load", (e) => this.onUploadDone(e, 'load'))
req.addEventListener("abort", (e) => this.onUploadDone(e)) req.addEventListener("abort", (e) => this.onUploadDone(e, 'abort'))
req.addEventListener("error", (e) => this.onUploadDone(e)) req.addEventListener("error", (e) => this.onUploadDone(e, 'error'))
const formData = new FormData(this.$refs.form); const formData = new FormData(this.$refs.form);
formData.append('csrfmiddlewaretoken', getCsrf()) formData.append('csrfmiddlewaretoken', getCsrf())
@ -87,8 +87,8 @@ export default {
this.total = event.total this.total = event.total
}, },
onUploadDone(event) { onUploadDone(event, eventName) {
this.$emit("load", event) this.$emit(eventName, event)
this._resetUpload(this.STATE.DEFAULT, true) this._resetUpload(this.STATE.DEFAULT, true)
}, },

View File

@ -0,0 +1,195 @@
<template>
<div>
<input type="hidden" :name="_prefix + 'TOTAL_FORMS'" :value="items.length || 0"/>
<template v-for="(value,name) in formData.management" v-bind:key="name">
<input type="hidden" :name="_prefix + name.toUpperCase()"
:value="value"/>
</template>
<a-rows ref="rows" :set="set"
:columns="visibleFields" :columnsOrderable="columnsOrderable"
:orderable="orderable" @move="moveItem" @colmove="onColumnMove"
@cell="e => $emit('cell', e)">
<template #header-head>
<template v-if="orderable">
<th style="max-width:2em" :title="orderField.label"
:aria-label="orderField.label"
:aria-description="orderField.help || ''">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
<slot name="rows-header-head"></slot>
</template>
</template>
<template #row-head="data">
<input v-if="orderable" type="hidden"
:name="_prefix + data.row + '-' + orderBy"
:value="data.row"/>
<input type="hidden" :name="_prefix + data.row + '-id'"
:value="data.item ? data.item.id : ''"/>
<template v-for="field of hiddenFields" v-bind:key="field.name">
<input type="hidden"
v-if="!(field.name in ['id', orderBy])"
:name="_prefix + data.row + '-' + field.name"
:value="field.value in [null, undefined] ? data.item.data[name] : field.value"/>
</template>
<slot name="row-head" v-bind="data">
<td v-if="orderable">{{ data.row+1 }}</td>
</slot>
</template>
<template v-for="(field,slot) of fieldSlots" v-bind:key="field.name"
v-slot:[slot]="data">
<slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name">
<div class="field">
<div class="control">
<slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/>
</div>
<p v-for="[error,index] in data.item.error(field.name)" class="help is-danger" v-bind:key="index">
{{ error }}
</p>
</div>
</slot>
</template>
<template #row-tail="data">
<slot v-if="$slots['row-tail']" name="row-tail" v-bind="data"/>
<td class="align-right pr-0">
<button type="button" class="button square"
@click.stop="removeItem(data.row, data.item)"
:title="labels.remove_item"
:aria-label="labels.remove_item">
<span class="icon"><i class="fa fa-trash" /></span>
</button>
</td>
</template>
</a-rows>
<div class="a-formset-footer flex-row">
<div class="flex-grow-1 flex-row">
<slot name="footer"/>
</div>
<div class="flex-grow-1 align-right">
<button type="button" class="button square is-warning p-2"
@click="reset()"
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2"
@click="onActionAdd"
:title="labels.add_item"
:aria-label="labels.add_item"
>
<span class="icon">
<i class="fa fa-plus"/></span>
</button>
</div>
</div>
</div>
</template>
<script>
import {cloneDeep} from 'lodash'
import Model, {Set} from '../model'
import ARows from './ARows'
export default {
emit: ['cell', 'move', 'colmove', 'load'],
components: {ARows},
props: {
labels: Object,
//! If provided call this function instead of adding an item to rows on "+" button click.
actionAdd: Function,
//! If True, columns can be reordered
columnsOrderable: Boolean,
//! Field name used for ordering
orderBy: String,
//! Formset data as returned by get_formset_data
formData: Object,
//! Model class used for item's set
model: {type: Function, default: Model},
//! initial data set load at mount
initials: Array,
},
data() {
return {
set: new Set(Model),
}
},
computed: {
// ---- fields
_prefix() { return this.formData.prefix ? this.formData.prefix + '-' : '' },
fields() { return this.formData.fields },
orderField() { return this.orderBy && this.fields.find(f => f.name == this.orderBy) },
orderable() { return !!this.orderField },
hiddenFields() { return this.fields.filter(f => f.hidden && !(this.orderable && f == this.orderField)) },
visibleFields() { return this.fields.filter(f => !f.hidden) },
fieldSlots() { return this.visibleFields.reduce(
(slots, f) => ({...slots, ['row-' + f.name]: f}),
{}
)},
items() { return this.set.items },
rows() { return this.$refs.rows },
},
methods: {
onCellEvent(event) { this.$emit('cell', event) },
onColumnMove(event) { this.$emit('colmove', event) },
onActionAdd() {
if(this.actionAdd)
return this.actionAdd(this)
this.set.push()
},
moveItem(event) {
const {from, to} = event
const set_ = event.set || this.set
set_.move(from, to);
this.$emit('move', {...event, seŧ: set_})
},
removeItem(row) {
const item = this.items[row]
if(item.id) {
// TODO
}
else {
this.items.splice(row,1)
}
},
//! Load items into set
load(items=[], reset=false) {
if(reset)
this.set.items = []
for(var item of items)
this.set.push(cloneDeep(item))
this.$emit('load', items)
},
//! Reset forms to initials
reset() {
this.load(this.initials || [], true)
},
},
mounted() {
this.reset()
}
}
</script>

View File

@ -6,6 +6,7 @@
<div class="modal-card-title"> <div class="modal-card-title">
<slot name="title">{{ title }}</slot> <slot name="title">{{ title }}</slot>
</div> </div>
<slot name="bar"></slot>
<button type="button" class="delete square" aria-label="close" @click="close"> <button type="button" class="delete square" aria-label="close" @click="close">
<span class="icon"> <span class="icon">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>

View File

@ -27,12 +27,15 @@ import {isReactive, toRefs} from 'vue'
import Model from '../model' import Model from '../model'
export default { export default {
emit: ['move', 'cell'], emits: ['move', 'cell'],
props: { props: {
//! Item to display in row //! Item to display in row
item: Object, item: {type: Object, default: () => ({})},
//! Columns to display, as items' attributes //! Columns to display, as items' attributes
//! - name: field name / item attribute value
//! - label: display label
//! - help: help text
columns: Array, columns: Array,
//! Default cell's info //! Default cell's info
cell: {type: Object, default() { return {row: 0}}}, cell: {type: Object, default() { return {row: 0}}},

View File

@ -1,34 +1,38 @@
<template> <template>
<table class="table is-stripped is-fullwidth"> <table class="table is-stripped is-fullwidth">
<thead> <thead>
<a-row :item="labels" :columns="columns" :orderable="orderable" <a-row :columns="columnNames"
@move="$emit('colmove', $event)"> :orderable="columnsOrderable" cellTag="th"
@move="moveColumn">
<template v-if="$slots['header-head']" v-slot:head="data"> <template v-if="$slots['header-head']" v-slot:head="data">
<slot name="header-head" v-bind="data"/> <slot name="header-head" v-bind="data"/>
</template> </template>
<template v-if="$slots['header-tail']" v-slot:tail="data"> <template v-if="$slots['header-tail']" v-slot:tail="data">
<slot name="header-tail" v-bind="data"/> <slot name="header-tail" v-bind="data"/>
</template> </template>
<template v-for="column of columns" v-bind:key="column.name"
v-slot:[column.name]="data">
<slot :name="'header-' + column.name" v-bind="data">
{{ column.label }}
<span v-if="column.help" class="icon small"
:title="column.help">
<i class="fa fa-circle-question"/>
</span>
</slot>
</template>
</a-row> </a-row>
</thead> </thead>
<tbody> <tbody>
<slot name="head"/> <slot name="head"/>
<template v-for="(item,row) in items" :key="row"> <template v-for="(item,row) in items" :key="row">
<!-- data-index comes from AList component drag & drop --> <!-- data-index comes from AList component drag & drop -->
<a-row :item="item" :cell="{row}" :columns="columns" :data-index="row" <a-row :item="item" :cell="{row}" :columns="columnNames" :data-index="row"
:data-row="row" :data-row="row"
:draggable="orderable" :draggable="orderable"
@dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"
@cell="onCellEvent(row, $event)"> @cell="onCellEvent(row, $event)">
<template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data"> <template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data">
<template v-if="slot == 'head' || slot == 'tail'"> <slot :name="name" v-bind="data"/>
<slot :name="name" v-bind="data"/>
</template>
<template v-else>
<div>
<slot :name="name" v-bind="data"/>
</div>
</template>
</template> </template>
</a-row> </a-row>
</template> </template>
@ -43,28 +47,38 @@ import ARow from './ARow.vue'
const Component = { const Component = {
extends: AList, extends: AList,
components: { ARow }, components: { ARow },
emit: ['cell', 'colmove'], //! Event:
//! - cell(event): an event occured inside cell
//! - colmove({from,to}), colmove(): columns moved
emits: ['cell', 'colmove'],
props: { props: {
...AList.props, ...AList.props,
//! Ordered list of columns, as objects with:
//! - name: item attribute value
//! - label: display label
//! - help: help text
//! - hidden: if true, field is hidden
columns: Array, columns: Array,
labels: Object, //! If True, columns are orderable
columnsOrderable: Boolean,
}, },
data() { data() {
return { return {
...super.data, ...super.data,
// TODO: add observer
columns_: [...this.columns],
extraItem: new this.set.model(), extraItem: new this.set.model(),
} }
}, },
computed: { computed: {
rowCells() { columnNames() { return this.columns_.map(c => c.name) },
const cells = [] columnLabels() { return this.columns_.reduce(
for(var row in this.items) (labels, c) => ({...labels, [c.name]: c.label}),
cells.push({row}) {}
}, )},
rowSlots() { rowSlots() {
return Object.keys(this.$slots).filter(x => x.startsWith('row-')) return Object.keys(this.$slots).filter(x => x.startsWith('row-'))
.map(x => [x, x.slice(4)]) .map(x => [x, x.slice(4)])
@ -72,6 +86,25 @@ const Component = {
}, },
methods: { methods: {
// TODO: use in tracklist
sortColumns(names) {
const ordered = names.map(n => this.columns_.find(c => c.name == n)).filter(c => !!c);
const remaining = this.columns_.filter(c => names.indexOf(c.name) == -1)
this.columns_ = [...ordered, ...remaining]
this.$emit('colmove')
},
/**
* Move column using provided event object (as `{from, to}`)
*/
moveColumn(event) {
const {from, to} = event
const value = this.columns_[from]
this.columns_.splice(from, 1)
this.columns_.splice(to, 0, value)
this.$emit('colmove', event)
},
/** /**
* React on 'cell' event, re-emitting it with additional values: * React on 'cell' event, re-emitting it with additional values:
* - `set`: data set * - `set`: data set

View File

@ -1,63 +1,99 @@
<template> <template>
<div class="a-select-file"> <a-modal ref="modal" :title="title">
<div ref="list" :class="['a-select-file-list', listClass]"> <template #bar>
<!-- upload --> <button type="button" class="button small mr-3" v-if="panel == LIST"
<form ref="uploadForm" class="flex-column" v-if="state == STATE.DEFAULT"> @click="showPanel(UPLOAD)">
<div class="field flex-grow-1"> <span class="icon">
<label class="label">{{ uploadLabel }}</label> <i class="fa fa-upload"></i>
<input type="file" ref="uploadFile" :name="uploadFieldName" @change="onSubmit"/> </span>
</div> <span>{{ labels.upload }}</span>
<div class="flex-grow-1"> </button>
<slot name="upload-form"></slot>
</div> <button type="button" class="button small mr-3" v-else
</form> @click="showPanel(LIST)">
<div class="flex-column" v-else> <span class="icon">
<slot name="upload-preview" :upload="upload"></slot> <i class="fa fa-list"></i>
<div class="flex-row"> </span>
<progress :max="upload.total" :value="upload.loaded"/> <span>{{ labels.list }}</span>
<button type="button" class="button small square ml-2" @click="uploadAbort"> </button>
<span class="icon small"> </template>
<i class="fa fa-close"></i> <template #default>
</span> <a-file-upload ref="upload" v-if="panel == UPLOAD"
</button> :url="uploadUrl"
:label="uploadLabel" :field-name="uploadFieldName"
@load="uploadDone">
<template #form="data">
<slot name="upload-form" v-bind="data"></slot>
</template>
<template #preview="data">
<slot name="upload-preview" v-bind="data"></slot>
</template>
</a-file-upload>
<div class="a-select-file" v-else>
<div ref="list"
:class="['a-select-file-list', listClass]">
<!-- tiles -->
<div v-if="prevUrl">
<a href="#" @click="load(prevUrl)">
{{ labels.show_previous }}
</a>
</div>
<template v-for="item in items" v-bind:key="item.id">
<div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)">
<slot :item="item" :load="load" :lastUrl="lastUrl"></slot>
<a-action-button v-if="deleteUrl"
class="has-text-danger small float-right"
icon="fa fa-trash"
:confirm="labels.confirm_delete"
method="DELETE"
:url="deleteUrl.replace('123', item.id)"
@done="load(lastUrl)">
</a-action-button>
</div>
</template>
<div v-if="nextUrl">
<a href="#" @click="load(nextUrl)">
{{ labels.show_next }}
</a>
</div>
</div> </div>
</div> </div>
</template>
<!-- tiles --> <template #footer>
<div v-if="prevUrl"> <slot name="footer" :item="item">
<a href="#" @click="load(prevUrl)"> <span class="mr-3" v-if="item">{{ item.name }}</span>
{{ prevLabel }} </slot>
</a> <button type="button" v-if="panel == LIST" class="button align-right"
</div> @click="selected">
{{ labels.select_file }}
<template v-for="item in items" v-bind:key="item.id"> </button>
<div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)"> </template>
<slot :item="item" :load="load" :lastUrl="lastUrl"></slot> </a-modal>
</div>
</template>
<div v-if="nextUrl">
<a href="#" @click="load(nextUrl)">
{{ nextLabel }}
</a>
</div>
</div>
<div class="a-select-footer">
<slot name="footer" :item="item" :items="items"></slot>
</div>
</div>
</template> </template>
<script> <script>
import {getCsrf} from "../model" import AModal from "./AModal"
import AActionButton from "./AActionButton"
import AFileUpload from "./AFileUpload"
export default { export default {
emit: ["select"],
components: {AActionButton, AFileUpload, AModal},
props: { props: {
name: { type: String }, title: { type: String },
labels: Object,
listClass: {type: String, default: ""}, listClass: {type: String, default: ""},
prevLabel: { type: String, default: "Prev" },
nextLabel: { type: String, default: "Next" }, // List url
listUrl: { type: String }, listUrl: { type: String },
// URL to delete an item, where "123" is replaced by
// the item id.
deleteUrl: {type: String },
uploadUrl: { type: String }, uploadUrl: { type: String },
uploadFieldName: { type: String, default: "file" }, uploadFieldName: { type: String, default: "file" },
uploadLabel: { type: String, default: "Upload a file" }, uploadLabel: { type: String, default: "Upload a file" },
@ -65,91 +101,63 @@ export default {
data() { data() {
return { return {
STATE: { LIST: 0,
DEFAULT: 0, UPLOAD: 1,
UPLOADING: 1,
},
state: 0,
panel: 0,
item: null, item: null,
items: [], items: [],
nextUrl: "", nextUrl: "",
prevUrl: "", prevUrl: "",
lastUrl: "", lastUrl: "",
upload: {},
} }
}, },
methods: { methods: {
open() {
this.$refs.modal.open()
},
close() {
this.$refs.modal.close()
},
showPanel(panel) {
this.panel = panel
},
load(url) { load(url) {
fetch(url || this.listUrl).then( return fetch(url || this.listUrl).then(
response => response.ok ? response.json() : Promise.reject(response) response => response.ok ? response.json() : Promise.reject(response)
).then(data => { ).then(data => {
this.lastUrl = url this.lastUrl = url
this.nextUrl = data.next this.nextUrl = data.next
this.prevUrl = data.previous this.prevUrl = data.previous
this.items = data.results this.items = data.results
this.showPanel(this.LIST)
this.$forceUpdate() this.$forceUpdate()
this.$refs.list.scroll(0, 0) this.$refs.list.scroll(0, 0)
return this.items
}) })
}, },
//! Select an item
select(item) { select(item) {
this.item = item; this.item = item;
}, },
// ---- upload //! User click on select button (confirm selection)
uploadAbort() { selected() {
this.upload.request && this.upload.request.abort() this.$emit("select", this.item)
this.close()
}, },
onSubmit() { uploadDone(reload=false) {
const [file] = this.$refs.uploadFile.files reload && this.load().then(items => {
if(!file) this.item = items[0]
return })
this._setUploadFile(file)
const req = new XMLHttpRequest()
req.open("POST", this.uploadUrl || this.listUrl)
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
req.addEventListener("load", (e) => this.onUploadDone(e, true))
req.addEventListener("abort", (e) => this.onUploadDone(e))
req.addEventListener("error", (e) => this.onUploadDone(e))
const formData = new FormData(this.$refs.uploadForm);
formData.append('csrfmiddlewaretoken', getCsrf())
req.send(formData)
this._resetUpload(this.STATE.UPLOADING, false, req)
}, },
onUploadProgress(event) {
this.upload.loaded = event.loaded
this.upload.total = event.total
},
onUploadDone(reload=false) {
this._resetUpload(this.STATE.DEFAULT, true)
reload && this.load()
},
_setUploadFile(file) {
this.upload.file = file
this.upload.fileURL = file && URL.createObjectURL(file)
},
_resetUpload(state, resetFile=false, request=null) {
this.state = state
this.upload.loaded = 0
this.upload.total = 0
this.upload.request = request
if(resetFile)
this.upload.file = null
}
}, },
mounted() { mounted() {

View File

@ -1,155 +1,83 @@
<template> <template>
<div class="a-playlist-editor"> <div class="a-playlist-editor">
<a-modal ref="modal" :title="labels && labels.add_sound"> <a-select-file ref="select-file"
<template #default> :title="labels && labels.add_sound"
<a-file-upload ref="file-upload" :url="soundUploadUrl" :label="labels.select_file" submitLabel="" @load="uploadDone" :labels="labels"
> :list-url="soundListUrl"
<template #preview="{upload}"> :deleteUrl="soundDeleteUrl"
<slot name="upload-preview" :upload="upload"></slot> :uploadUrl="soundUploadUrl"
</template> :uploadLabel="labels.select_file"
<template #form> @select="selected"
<slot name="upload-form"></slot> >
</template> <template #upload-preview="{upload}">
</a-file-upload> <slot name="upload-preview" :upload="upload"></slot>
</template> </template>
<template #footer> <template #upload-form>
<button type="button" class="button" <slot name="upload-form"></slot>
@click.stop="$refs['file-upload'].submit()">
<span class="icon">
<i class="fa fa-upload"></i>
</span>
<span>{{ labels.submit }}</span>
</button>
</template> </template>
</a-modal> <template #default="{item}">
<audio controls :src="item.url"></audio>
<label class="label small flex-grow-1">{{ item.name }}</label>
</template>
</a-select-file>
<slot name="top" :set="set" :items="set.items"></slot> <a-form-set ref="formset" :form-data="formData" :labels="labels"
<a-rows :set="set" :columns="allColumns" :initials="initData.items"
:labels="allColumnsLabels" :allow-create="true" :orderable="true" order-by="position"
@move="listItemMove"> :action-add="actionAdd">
<template v-for="[name,slot] of rowsSlots" :key="slot" <template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data"> v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/> <slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template> </template>
</a-rows>
<div class="flex-row"> <template #row-sound="{item,inputName}">
<div class="flex-grow-1 flex-row"> <label>{{ item.data.name }}</label><br>
</div> <audio controls :src="item.data.url"/>
<div class="flex-grow-1 align-right"> <input type="hidden" :name="inputName" :value="item.data.sound"/>
<button type="button" class="button square is-warning p-2" </template>
@click="loadData({items: this.initData.items},true)" </a-form-set>
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2"
@click="$refs.modal.open()"
:title="labels.add_sound"
:aria-label="labels.add_sound"
>
<span class="icon">
<i class="fa fa-plus"/></span>
</button>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
// import {dropRightWhile, cloneDeep, isEqual} from 'lodash' import AFormSet from './AFormSet'
import {cloneDeep} from 'lodash' import ASelectFile from "./ASelectFile"
import Model, {Set} from '../model'
// import AActionButton from './AActionButton'
import ARows from './ARows'
import AModal from "./AModal"
import AFileUpload from "./AFileUpload"
export default { export default {
components: {ARows, AModal, AFileUpload}, components: {AFormSet, ASelectFile},
props: { props: {
initData: Object, formData: Object,
dataPrefix: String,
labels: Object, labels: Object,
settingsUrl: String, // initial datas
initData: Object,
soundListUrl: String, soundListUrl: String,
soundUploadUrl: String, soundUploadUrl: String,
player: Object, soundDeleteUrl: String,
columns: {
type: Array,
default: () => ['name', "type", 'is_public', 'is_downloadable']
},
},
data() {
return {
set: new Set(Model),
}
}, },
computed: { computed: {
player_() {
return this.player || window.aircox.player
},
allColumns() {
return [...this.columns, "delete"]
},
allColumnsLabels() {
return {...this.labels, ...this.initData.fields}
},
items() {
return this.set.items
},
rowsSlots() { rowsSlots() {
return Object.keys(this.$slots) return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-')) .filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x]) .map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
}, },
}, },
methods: { methods: {
listItemMove({from, to, set}) { actionAdd() {
set.move(from, to); this.$refs['select-file'].open()
}, },
/** selected(item) {
* Load initial data const data = {
*/ "sound": item.id,
loadData({items=[] /*, settings=null*/}, reset=false) { "name": item.name,
if(reset) { "url": item.url,
this.set.items = [] "broadcast": item.broadcast,
} }
for(var index in items) this.$refs.formset.set.push(data)
this.set.push(cloneDeep(items[index]))
// if(settings)
// this.settingsSaved(settings)
}, },
uploadDone(event) {
const req = event.target
if(req.status == 201) {
const item = JSON.parse(req.response)
this.set.push(item)
this.$refs.modal.close()
}
},
},
watch: {
initData(val) {
this.loadData(val)
},
},
mounted() {
this.initData && this.loadData(this.initData)
}, },
} }
</script> </script>

View File

@ -12,7 +12,7 @@
<span class="icon is-small"> <span class="icon is-small">
<i class="fa fa-pencil"></i> <i class="fa fa-pencil"></i>
</span> </span>
<span>Texte</span> <span>{{ labels.text }}</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
@ -21,13 +21,21 @@
<span class="icon is-small"> <span class="icon is-small">
<i class="fa fa-list"></i> <i class="fa fa-list"></i>
</span> </span>
<span>Liste</span> <span>{{ labels.list }}</span>
</button>
</p>
<p class="control ml-3">
<button type="button" class="button is-info square"
:title="labels.settings"
@click="$refs.settings.open()">
<span class="icon is-small">
<i class="fa fa-cog"></i>
</span>
</button> </button>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<slot name="top" :set="set" :columns="columns" :items="items"/>
<section v-show="page == Page.Text" class="panel"> <section v-show="page == Page.Text" class="panel">
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20" <textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
@change="updateList" @change="updateList"
@ -35,61 +43,20 @@
</section> </section>
<section v-show="page == Page.List" class="panel"> <section v-show="page == Page.List" class="panel">
<a-rows :set="set" :columns="columns" :labels="initData.fields" <a-form-set ref="formset"
:orderable="true" @move="listItemMove" @colmove="columnMove" :form-data="formData" :initials="initData.items"
@cell="onCellEvent"> :columnsOrderable="true" :labels="labels"
order-by="position"
@load="updateInput" @colmove="onColumnMove" @move="updateInput"
@cell="onCellEvent">
<template v-for="[name,slot] of rowsSlots" :key="slot" <template v-for="[name,slot] of rowsSlots" :key="slot"
v-slot:[slot]="data"> v-slot:[slot]="data">
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/> <slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
</template> </template>
</a-form-set>
<template v-slot:row-tail="data">
<slot v-if="$slots['row-tail']" :name="row-tail" v-bind="data"/>
<td class="align-right pr-0">
<button type="button" class="button square"
@click.stop="items.splice(data.row,1)"
:title="labels.remove_item"
:aria-label="labels.remove_item">
<span class="icon"><i class="fa fa-trash" /></span>
</button>
</td>
</template>
</a-rows>
</section> </section>
<div class="flex-row"> <a-modal ref="settings" :title="labels.settings">
<div class="flex-grow-1 flex-row">
<div class="field">
<p class="control">
<button type="button" class="button is-info"
@click="$refs.settings.open()">
<span class="icon is-small">
<i class="fa fa-cog"></i>
</span>
<span>Options</span>
</button>
</p>
</div>
</div>
<div class="flex-grow-1 align-right">
<button type="button" class="button square is-warning p-2"
@click="loadData({items: this.initData.items},true)"
:title="labels.discard_changes"
:aria-label="labels.discard_changes"
>
<span class="icon"><i class="fa fa-rotate" /></span>
</button>
<button type="button" class="button square is-primary p-2" v-if="page == Page.List"
@click="this.set.push(new this.set.model())"
:title="labels.add_item"
:aria-label="labels.add_item"
>
<span class="icon"><i class="fa fa-plus"/></span>
</button>
</div>
</div>
<a-modal ref="settings" title="Options">
<template #default> <template #default>
<div class="field"> <div class="field">
<label class="label" style="vertical-align: middle"> <label class="label" style="vertical-align: middle">
@ -97,12 +64,14 @@
</label> </label>
<table class="table is-bordered" <table class="table is-bordered"
style="vertical-align: middle"> style="vertical-align: middle">
<tr> <tr v-if="$refs.formset">
<a-row :columns="columns" :item="initData.fields" <a-row :columns="$refs.formset.rows.columnNames"
@move="formatMove" :orderable="true"> :item="$refs.formset.rows.columnLabels"
@move="$refs.formset.rows.moveColumn"
>
<template v-slot:cell-after="{cell}"> <template v-slot:cell-after="{cell}">
<td style="cursor:pointer;" v-if="cell.col < columns.length-1"> <td style="cursor:pointer;" v-if="cell.col < $refs.formset.rows.columns_.length-1">
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})" <span class="icon" @click="$refs.formset.rows.moveColumn({from: cell.col, to: cell.col+1})"
><i class="fa fa-left-right"/> ><i class="fa fa-left-right"/>
</span> </span>
</td> </td>
@ -143,16 +112,14 @@
</div> </div>
</template> </template>
</a-modal> </a-modal>
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
</div> </div>
</template> </template>
<script> <script>
import {dropRightWhile, cloneDeep, isEqual} from 'lodash' import {dropRightWhile, cloneDeep, isEqual} from 'lodash'
import Model, {Set} from '../model'
import AActionButton from './AActionButton' import AActionButton from './AActionButton'
import AFormSet from './AFormSet'
import ARow from './ARow' import ARow from './ARow'
import ARows from './ARows'
import AModal from "./AModal" import AModal from "./AModal"
/// Page display /// Page display
@ -161,12 +128,14 @@ export const Page = {
} }
export default { export default {
components: { AActionButton, ARow, ARows, AModal }, components: { AActionButton, AFormSet, ARow, AModal },
props: { props: {
formData: Object,
labels: Object,
///! initial data as: {items: [], fields: {column_name: label, settings: {}} ///! initial data as: {items: [], fields: {column_name: label, settings: {}}
initData: Object, initData: Object,
dataPrefix: String, dataPrefix: String,
labels: Object,
settingsUrl: String, settingsUrl: String,
defaultColumns: { defaultColumns: {
type: Array, type: Array,
@ -175,13 +144,12 @@ export default {
data() { data() {
const settings = { const settings = {
tracklist_editor_columns: this.defaultColumns, // tracklist_editor_columns: this.columns,
tracklist_editor_sep: ' -- ', tracklist_editor_sep: ' -- ',
} }
return { return {
Page: Page, Page: Page,
page: Page.Text, page: Page.Text,
set: new Set(Model),
extraData: {}, extraData: {},
settings, settings,
savedSettings: cloneDeep(settings), savedSettings: cloneDeep(settings),
@ -189,6 +157,9 @@ export default {
}, },
computed: { computed: {
rows() { return this.$refs.formset && this.$refs.formset.rows },
columns() { return this.rows && this.rows.columns_ || [] },
settingsChanged() { settingsChanged() {
var k = Object.keys(this.savedSettings) var k = Object.keys(this.savedSettings)
.findIndex(k => !isEqual(this.settings[k], this.savedSettings[k])) .findIndex(k => !isEqual(this.settings[k], this.savedSettings[k]))
@ -204,25 +175,9 @@ export default {
get() { return this.settings.tracklist_editor_sep } get() { return this.settings.tracklist_editor_sep }
}, },
columns: {
set(value) {
var cols = value.filter(x => x in this.defaultColumns)
var left = this.defaultColumns.filter(x => !(x in cols))
value = cols.concat(left)
this.settings.tracklist_editor_columns = value
},
get() {
return this.settings.tracklist_editor_columns
}
},
items() {
return this.set.items
},
rowsSlots() { rowsSlots() {
return Object.keys(this.$slots) return Object.keys(this.$slots)
.filter(x => x.startsWith('row-') || x.startsWith('rows-')) .filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-'))
.map(x => [x, x.startsWith('rows-') ? x.slice(5) : x]) .map(x => [x, x.startsWith('rows-') ? x.slice(5) : x])
}, },
}, },
@ -235,35 +190,21 @@ export default {
} }
}, },
formatMove({from, to}) { onColumnMove() {
const value = this.columns[from] this.settings.tracklist_editor_columns = this.$refs.formset.rows.columnNames
this.settings.tracklist_editor_columns.splice(from, 1) if(this.page == this.Page.List)
this.settings.tracklist_editor_columns.splice(to, 0, value)
if(this.page == Page.Text)
this.updateList()
else
this.updateInput() this.updateInput()
}, else
this.updateList()
columnMove({from, to}) {
const value = this.columns[from]
this.columns.splice(from, 1)
this.columns.splice(to, 0, value)
this.updateInput()
},
listItemMove({from, to, set}) {
set.move(from, to);
this.updateInput()
}, },
updateList() { updateList() {
const items = this.toList(this.$refs.textarea.value) const items = this.toList(this.$refs.textarea.value)
this.set.reset(items) this.$refs.formset.set.reset(items)
}, },
updateInput() { updateInput() {
const input = this.toText(this.items) const input = this.toText(this.$refs.formset.items)
this.$refs.textarea.value = input this.$refs.textarea.value = input
}, },
@ -271,6 +212,7 @@ export default {
* From input and separator, return list of items. * From input and separator, return list of items.
*/ */
toList(input) { toList(input) {
const columns = this.$refs.formset.rows.columns_
var lines = input.split('\n') var lines = input.split('\n')
var items = [] var items = []
@ -281,11 +223,11 @@ export default {
var lineBits = line.split(this.separator) var lineBits = line.split(this.separator)
var item = {} var item = {}
for(var col in this.columns) { for(var col in columns) {
if(col >= lineBits.length) if(col >= lineBits.length)
break break
const attr = this.columns[col] const column = columns[col]
item[attr] = lineBits[col].trim() item[column.name] = lineBits[col].trim()
} }
item && items.push(item) item && items.push(item)
} }
@ -296,14 +238,15 @@ export default {
* From items and separator return a string * From items and separator return a string
*/ */
toText(items) { toText(items) {
const columns = this.$refs.formset.rows.columns_
const sep = ` ${this.separator.trim()} ` const sep = ` ${this.separator.trim()} `
const lines = [] const lines = []
for(let item of items) { for(let item of items) {
if(!item) if(!item)
continue continue
var line = [] var line = []
for(var col of this.columns) for(var col of columns)
line.push(item.data[col] || '') line.push(item.data[col.name] || '')
line = dropRightWhile(line, x => !x || !('' + x).trim()) line = dropRightWhile(line, x => !x || !('' + x).trim())
line = line.join(sep).trimRight() line = line.join(sep).trimRight()
lines.push(line) lines.push(line)
@ -331,31 +274,15 @@ export default {
this.$refs.settings.close() this.$refs.settings.close()
this.savedSettings = cloneDeep(this.settings) this.savedSettings = cloneDeep(this.settings)
}, },
/**
* Load initial data
*/
loadData({items=[], settings=null}, reset=false) {
if(reset) {
this.set.items = []
}
for(var index in items)
this.set.push(cloneDeep(items[index]))
if(settings)
this.settingsSaved(settings)
this.updateInput()
},
},
watch: {
initData(val) {
this.loadData(val)
},
}, },
mounted() { mounted() {
this.initData && this.loadData(this.initData) const settings = this.initData && this.initData.settings
this.page = this.items.length ? Page.List : Page.Text if(settings) {
this.settingsSaved(settings)
this.rows.sortColumns(settings.tracklist_editor_columns)
}
this.page = this.initData.items.length ? Page.List : Page.Text
}, },
} }
</script> </script>

View File

@ -1,4 +1,4 @@
import AActionButton from './AActionButton' import AActionButton from './AActionButton.vue'
import AAutocomplete from './AAutocomplete' import AAutocomplete from './AAutocomplete'
import ACarousel from './ACarousel' import ACarousel from './ACarousel'
import ADropdown from "./ADropdown" import ADropdown from "./ADropdown"
@ -16,6 +16,8 @@ import AFileUpload from "./AFileUpload"
import ASelectFile from "./ASelectFile" import ASelectFile from "./ASelectFile"
import AStatistics from './AStatistics' import AStatistics from './AStatistics'
import AStreamer from './AStreamer' import AStreamer from './AStreamer'
import AFormSet from './AFormSet'
import ATrackListEditor from './ATrackListEditor' import ATrackListEditor from './ATrackListEditor'
import ASoundListEditor from './ASoundListEditor' import ASoundListEditor from './ASoundListEditor'
@ -37,5 +39,6 @@ export const admin = {
export const dashboard = { export const dashboard = {
...base, ...base,
AActionButton, AFileUpload, ASelectFile, AModal, ATrackListEditor, ASoundListEditor AActionButton, AFileUpload, ASelectFile, AModal,
AFormSet, ATrackListEditor, ASoundListEditor
} }

View File

@ -17,13 +17,12 @@ const DashboardApp = {
methods: { methods: {
...App.methods, ...App.methods,
fileSelected(select, cover, input, modal) { fileSelected(select, input, preview) {
console.log("file!")
const item = this.$refs[select].item const item = this.$refs[select].item
if(item) { if(item) {
this.$refs[cover].src = item.file
this.$refs[input].value = item.id this.$refs[input].value = item.id
modal && this.$refs[modal].close() if(preview)
preview.src = item.file
} }
}, },
} }

View File

@ -2,9 +2,6 @@
* This module includes code available for both the public website and * This module includes code available for both the public website and
* administration interface) * administration interface)
*/ */
//-- vendor
import '@fortawesome/fontawesome-free/css/all.min.css';
//-- aircox //-- aircox
import App, {PlayerApp} from './app' import App, {PlayerApp} from './app'

View File

@ -2,8 +2,11 @@ import Model from './model';
export default class Sound extends Model { export default class Sound extends Model {
constructor({sound={}, ...data}={}, options={}) {
// flatten EpisodeSound and sound data
super({...sound, ...data}, options)
}
get name() { return this.data.name } get name() { return this.data.name }
get src() { return this.data.url } get src() { return this.data.url }
static getId(data) { return data.pk }
} }

View File

@ -1,9 +1,9 @@
@use "./vars"; @use "./vars";
@use "./components"; @use "./components";
@import "~bulma/sass/utilities/_all.sass"; @import "bulma/sass/utilities/_all.sass";
@import "~bulma/sass/elements/button"; @import "bulma/sass/elements/button";
@import "~bulma/sass/components/navbar"; @import "bulma/sass/components/navbar";
// enforce button usage inside custom application // enforce button usage inside custom application

View File

@ -1,4 +1,5 @@
@import 'v-calendar/style.css'; @import 'v-calendar/style.css';
@import '@fortawesome/fontawesome-free/css/all.min.css';
// ---- bulma // ---- bulma
$body-color: #000; $body-color: #000;
@ -6,29 +7,29 @@ $title-color: #000;
$modal-content-width: 80%; $modal-content-width: 80%;
@import "~bulma/sass/utilities/_all.sass"; @import "bulma/sass/utilities/_all.sass";
@import "~bulma/sass/base/_all"; @import "bulma/sass/base/_all";
@import "~bulma/sass/components/dropdown"; @import "bulma/sass/components/dropdown";
// @import "~bulma/sass/components/card"; // @import "bulma/sass/components/card";
@import "~bulma/sass/components/media"; @import "bulma/sass/components/media";
@import "~bulma/sass/components/message"; @import "bulma/sass/components/message";
@import "~bulma/sass/components/modal"; @import "bulma/sass/components/modal";
//@import "~bulma/sass/components/pagination"; //@import "bulma/sass/components/pagination";
@import "~bulma/sass/form/_all"; @import "bulma/sass/form/_all";
@import "~bulma/sass/grid/_all"; @import "bulma/sass/grid/_all";
@import "~bulma/sass/helpers/_all"; @import "bulma/sass/helpers/_all";
@import "~bulma/sass/layout/_all"; @import "bulma/sass/layout/_all";
@import "~bulma/sass/elements/box"; @import "bulma/sass/elements/box";
// @import "~bulma/sass/elements/button"; // @import "bulma/sass/elements/button";
@import "~bulma/sass/elements/container"; @import "bulma/sass/elements/container";
// @import "~bulma/sass/elements/content"; // @import "bulma/sass/elements/content";
@import "~bulma/sass/elements/icon"; @import "bulma/sass/elements/icon";
// @import "~bulma/sass/elements/image"; // @import "bulma/sass/elements/image";
// @import "~bulma/sass/elements/notification"; // @import "bulma/sass/elements/notification";
// @import "~bulma/sass/elements/progress"; // @import "bulma/sass/elements/progress";
@import "~bulma/sass/elements/table"; @import "bulma/sass/elements/table";
@import "~bulma/sass/elements/tag"; @import "bulma/sass/elements/tag";
//@import "~bulma/sass/elements/title"; //@import "bulma/sass/elements/title";

View File

@ -20,13 +20,18 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
import aircox.urls import aircox.urls
import aircox_streamer.urls
urlpatterns = aircox.urls.urls + [ urlpatterns = (
path("admin/", admin.site.urls), aircox.urls.urls
path("accounts/", include("django.contrib.auth.urls")), + aircox_streamer.urls.urls
path("ckeditor/", include("ckeditor_uploader.urls")), + [
path("filer/", include("filer.urls")), path("admin/", admin.site.urls),
] path("accounts/", include("django.contrib.auth.urls")),
path("ckeditor/", include("ckeditor_uploader.urls")),
path("filer/", include("filer.urls")),
]
)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static( urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(