create EpisodeSound & adapt; update list editors
This commit is contained in:
parent
bda4efe336
commit
78a8478da8
|
@ -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")
|
||||||
|
|
|
@ -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 ""
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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.find_episode_sound(sound)
|
||||||
return sound
|
return sound
|
||||||
|
|
||||||
|
def find_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()
|
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,39 @@ 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"]
|
||||||
|
|
||||||
|
|
||||||
TrackFormSet = modelformset_factory(
|
TrackFormSet = modelformset_factory(
|
||||||
models.Track,
|
models.Track,
|
||||||
fields=[
|
fields=[
|
||||||
|
"episode",
|
||||||
"position",
|
"position",
|
||||||
"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=(
|
||||||
|
"episode",
|
||||||
|
"sound",
|
||||||
"position",
|
"position",
|
||||||
"name",
|
"broadcast",
|
||||||
"type",
|
),
|
||||||
"is_public",
|
widgets={
|
||||||
"is_downloadable",
|
"broadcast": forms.CheckboxInput(),
|
||||||
"duration",
|
"episode": forms.HiddenInput(),
|
||||||
],
|
"sound": forms.HiddenInput(),
|
||||||
|
"position": forms.HiddenInput(),
|
||||||
|
},
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
extra=0,
|
extra=0,
|
||||||
)
|
)
|
||||||
"""Sound formset used in EpisodeUpdateView."""
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,18 +1,21 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 +35,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 +87,55 @@ 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"):
|
||||||
|
if order:
|
||||||
|
self = self.order_by(order)
|
||||||
|
return [
|
||||||
|
os.path.join(settings.MEDIA_ROOT, file)
|
||||||
|
for file in self.filter(file__isnull=False, is_removed=False).Values_list("file", flat=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
150
aircox/models/file.py
Normal 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)
|
|
@ -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>")
|
||||||
|
|
||||||
|
|
|
@ -1,64 +1,41 @@
|
||||||
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")
|
from .track import Track
|
||||||
|
from .controllers.playlist_import import PlaylistImport
|
||||||
|
|
||||||
|
|
||||||
__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, broadcast=True, order_by=True):
|
||||||
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 `order_by` is True, order by path.
|
||||||
"""
|
"""
|
||||||
if archive:
|
if broadcast:
|
||||||
self = self.archive()
|
self = self.broadcast()
|
||||||
if order_by:
|
if order_by:
|
||||||
self = self.order_by("file")
|
self = self.order_by("file")
|
||||||
return [
|
return [
|
||||||
|
@ -66,175 +43,147 @@ class SoundQuerySet(models.QuerySet):
|
||||||
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(
|
||||||
objects = SoundQuerySet.as_manager()
|
_("Broadcast"),
|
||||||
|
default=False,
|
||||||
|
help_text=_("The sound is broadcasted on air"),
|
||||||
|
)
|
||||||
|
|
||||||
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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
36
aircox/serializers/episode.py
Normal file
36
aircox/serializers/episode.py
Normal 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",
|
||||||
|
]
|
|
@ -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
|
@ -4,6 +4,8 @@ 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
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
|
@ -17,9 +19,9 @@ Context:
|
||||||
|
|
||||||
<{{ tag }}
|
<{{ tag }}
|
||||||
{% block tag-attrs %}
|
{% block tag-attrs %}
|
||||||
:labels="{% inline_labels %}"
|
: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 %}>
|
||||||
|
@ -29,11 +31,7 @@ Context:
|
||||||
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
|
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
|
||||||
:value="items.length || 0"/>
|
:value="items.length || 0"/>
|
||||||
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
|
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
|
||||||
{% if no_initial_form_count %}
|
|
||||||
:value="items.length || 0"
|
|
||||||
{% else %}
|
|
||||||
value="{{ formset.initial_form_count }}"
|
value="{{ formset.initial_form_count }}"
|
||||||
{% endif %}
|
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="{{ formset.prefix }}-MIN_NUM_FORMS"
|
<input type="hidden" name="{{ formset.prefix }}-MIN_NUM_FORMS"
|
||||||
value="{{ formset.min_num }}"/>
|
value="{{ formset.min_num }}"/>
|
||||||
|
@ -51,29 +49,33 @@ Context:
|
||||||
</th>
|
</th>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:row-head="{item,row}">
|
<template v-slot:row-head="{item,row,attr}">
|
||||||
{% block row-head %}
|
|
||||||
<td>
|
<td>
|
||||||
|
{% block row-head %}
|
||||||
[[ row+1 ]]
|
[[ row+1 ]]
|
||||||
<input type="hidden"
|
<input type="hidden"
|
||||||
:name="'{{ formset.prefix }}-' + row + '-position'"
|
:name="'{{ formset.prefix }}-' + row + '-position'"
|
||||||
:value="row"/>
|
:value="row"/>
|
||||||
<input t-if="item.data.id" type="hidden"
|
<input type="hidden"
|
||||||
:name="'{{ formset.prefix }}-' + row + '-id'"
|
:name="'{{ formset.prefix }}-' + row + '-id'"
|
||||||
:value="item.data.id || item.id"/>
|
:value="item.data.id || item.id "/>
|
||||||
|
|
||||||
{% for name, field in fields.items %}
|
{% for name, field in fields.items %}
|
||||||
{% if name != 'position' and field.widget.is_hidden %}
|
{% if name == related_field %}
|
||||||
<input type="hidden"
|
<input type="hidden"
|
||||||
:name="'{{ formset.prefix }}-' + row + '-{{ name }}'"
|
:name="'{{ formset.prefix }}-' + row + '-{{ name }}'"
|
||||||
v-model="item.data[attr]"/>
|
value="{{ object.id }}"/>
|
||||||
|
{% elif name != 'position' and field.widget.is_hidden %}
|
||||||
|
<input type="hidden"
|
||||||
|
:name="'{{ formset.prefix }}-' + row + '-{{ name }}'"
|
||||||
|
v-model="item.data['{{ name }}']"/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
</td>
|
</td>
|
||||||
{% endblock %}
|
|
||||||
</template>
|
</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 name != related_field and not field.widget.is_hidden and not field.is_readonly %}
|
||||||
<template v-slot:row-{{ name }}="{item,cell,value,attr,emit}">
|
<template v-slot:row-{{ name }}="{item,cell,value,attr,emit}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{% with full_name="'"|add:formset.prefix|add:"-' + cell.row + '-"|add:name|add:"'" %}
|
{% with full_name="'"|add:formset.prefix|add:"-' + cell.row + '-"|add:name|add:"'" %}
|
||||||
|
|
|
@ -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,11 @@
|
||||||
|
|
||||||
{% block tag-attrs %}
|
{% block tag-attrs %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
sound-list-url="{% url "api:sound-list" %}?program={{ object.pk }}&episode__isnull"
|
list-url="{% url "api:episodesound-list" %}"
|
||||||
|
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 %}"
|
||||||
|
:item-defaults="{episode: {{ object.pk }}}"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
|
@ -25,7 +32,7 @@ sound-upload-url="{% url "api:sound-list" %}"
|
||||||
{% with field.name as name %}
|
{% with field.name as name %}
|
||||||
{% with field.initial as value %}
|
{% with field.initial as value %}
|
||||||
{% with field.field as field %}
|
{% with field.field as field %}
|
||||||
{% if name in "episode,program" %}
|
{% if name in "episode,program,sound" %}
|
||||||
{% include "./form_field.html" with value=value hidden=True %}
|
{% 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">
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -8,10 +8,8 @@
|
||||||
<hr/>
|
<hr/>
|
||||||
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset %}
|
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset %}
|
||||||
<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 %}
|
||||||
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset %}
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
</a-episode>
|
</a-episode>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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")
|
||||||
|
@ -64,9 +69,15 @@ inline_labels_ = {
|
||||||
"remove_item": _("Remove"),
|
"remove_item": _("Remove"),
|
||||||
"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
|
||||||
"columns": _("Columns"),
|
"columns": _("Columns"),
|
||||||
"timestamp": _("Timestamp"),
|
"timestamp": _("Timestamp"),
|
||||||
|
@ -78,4 +89,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()}))
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
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
|
||||||
from ..filters import EpisodeFilters
|
from ..filters import EpisodeFilters
|
||||||
from .page import PageListView
|
from .page import PageListView
|
||||||
|
@ -63,38 +63,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("-broadcast", "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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -122,6 +123,7 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
||||||
for formset in formsets.values():
|
for formset in formsets.values():
|
||||||
if not formset.is_valid():
|
if not formset.is_valid():
|
||||||
invalid = True
|
invalid = True
|
||||||
|
breakpoint()
|
||||||
else:
|
else:
|
||||||
formset.save()
|
formset.save()
|
||||||
if invalid:
|
if invalid:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -198,7 +198,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 +227,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,
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1,63 +1,100 @@
|
||||||
<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>
|
<div class="a-select-file">
|
||||||
</button>
|
<a-file-upload ref="upload" v-if="panel == UPLOAD"
|
||||||
|
: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 ref="list" v-show="panel == 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 +102,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() {
|
||||||
|
|
|
@ -1,27 +1,25 @@
|
||||||
<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>
|
<slot name="top" :set="set" :items="set.items"></slot>
|
||||||
<a-rows :set="set" :columns="allColumns"
|
<a-rows :set="set" :columns="allColumns"
|
||||||
|
@ -31,6 +29,11 @@
|
||||||
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>
|
||||||
|
|
||||||
|
<template #row-sound="{item}">
|
||||||
|
<label>{{ item.data.name }}</label><br>
|
||||||
|
<audio controls :src="item.data.url"/>
|
||||||
|
</template>
|
||||||
</a-rows>
|
</a-rows>
|
||||||
|
|
||||||
<div class="flex-row">
|
<div class="flex-row">
|
||||||
|
@ -45,7 +48,7 @@
|
||||||
<span class="icon"><i class="fa fa-rotate" /></span>
|
<span class="icon"><i class="fa fa-rotate" /></span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="button square is-primary p-2"
|
<button type="button" class="button square is-primary p-2"
|
||||||
@click="$refs.modal.open()"
|
@click="$refs['select-file'].open()"
|
||||||
:title="labels.add_sound"
|
:title="labels.add_sound"
|
||||||
:aria-label="labels.add_sound"
|
:aria-label="labels.add_sound"
|
||||||
>
|
>
|
||||||
|
@ -61,25 +64,27 @@
|
||||||
import {cloneDeep} from 'lodash'
|
import {cloneDeep} from 'lodash'
|
||||||
import Model, {Set} from '../model'
|
import Model, {Set} from '../model'
|
||||||
|
|
||||||
// import AActionButton from './AActionButton'
|
|
||||||
import ARows from './ARows'
|
import ARows from './ARows'
|
||||||
import AModal from "./AModal"
|
//import AFileUpload from "./AFileUpload"
|
||||||
import AFileUpload from "./AFileUpload"
|
import ASelectFile from "./ASelectFile"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {ARows, AModal, AFileUpload},
|
components: {ARows, ASelectFile},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
|
// default values of items
|
||||||
|
itemDefaults: Object,
|
||||||
|
// initial datas
|
||||||
initData: Object,
|
initData: Object,
|
||||||
dataPrefix: String,
|
|
||||||
labels: Object,
|
labels: Object,
|
||||||
settingsUrl: String,
|
|
||||||
soundListUrl: String,
|
soundListUrl: String,
|
||||||
soundUploadUrl: String,
|
soundUploadUrl: String,
|
||||||
player: Object,
|
soundDeleteUrl: String,
|
||||||
|
|
||||||
columns: {
|
columns: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => ['name', "type", 'is_public', 'is_downloadable']
|
default: () => ['name', "broadcast"]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -95,7 +100,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
allColumns() {
|
allColumns() {
|
||||||
return [...this.columns, "delete"]
|
return ["sound", ...this.columns, "delete"]
|
||||||
},
|
},
|
||||||
|
|
||||||
allColumnsLabels() {
|
allColumnsLabels() {
|
||||||
|
@ -131,17 +136,18 @@ export default {
|
||||||
// this.settingsSaved(settings)
|
// this.settingsSaved(settings)
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadDone(event) {
|
selected(item) {
|
||||||
const req = event.target
|
const data = {
|
||||||
if(req.status == 201) {
|
...this.itemDefaults,
|
||||||
const item = JSON.parse(req.response)
|
"sound": item.id,
|
||||||
this.set.push(item)
|
"name": item.name,
|
||||||
this.$refs.modal.close()
|
"url": item.url,
|
||||||
|
"broadcast": item.broadcast,
|
||||||
}
|
}
|
||||||
|
this.set.push(data)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
initData(val) {
|
initData(val) {
|
||||||
this.loadData(val)
|
this.loadData(val)
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="top" :set="set" :columns="columns" :items="items"/>
|
<slot name="top" :set="set" :columns="allColumns" :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,7 +35,7 @@
|
||||||
|
|
||||||
</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-rows :set="set" :columns="allColumns" :labels="initData.fields"
|
||||||
:orderable="true" @move="listItemMove" @colmove="columnMove"
|
:orderable="true" @move="listItemMove" @colmove="columnMove"
|
||||||
@cell="onCellEvent">
|
@cell="onCellEvent">
|
||||||
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
||||||
|
@ -98,10 +98,10 @@
|
||||||
<table class="table is-bordered"
|
<table class="table is-bordered"
|
||||||
style="vertical-align: middle">
|
style="vertical-align: middle">
|
||||||
<tr>
|
<tr>
|
||||||
<a-row :columns="columns" :item="initData.fields"
|
<a-row :columns="allColumns" :item="initData.fields"
|
||||||
@move="formatMove" :orderable="true">
|
@move="formatMove" :orderable="true">
|
||||||
<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 < allColumns.length-1">
|
||||||
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
|
<span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})"
|
||||||
><i class="fa fa-left-right"/>
|
><i class="fa fa-left-right"/>
|
||||||
</span>
|
</span>
|
||||||
|
@ -143,7 +143,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
|
<slot name="bottom" :set="set" :columns="allColumns" :items="items"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
|
@ -175,7 +175,7 @@ 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 {
|
||||||
|
@ -204,7 +204,7 @@ export default {
|
||||||
get() { return this.settings.tracklist_editor_sep }
|
get() { return this.settings.tracklist_editor_sep }
|
||||||
},
|
},
|
||||||
|
|
||||||
columns: {
|
allColumns: {
|
||||||
set(value) {
|
set(value) {
|
||||||
var cols = value.filter(x => x in this.defaultColumns)
|
var cols = value.filter(x => x in this.defaultColumns)
|
||||||
var left = this.defaultColumns.filter(x => !(x in cols))
|
var left = this.defaultColumns.filter(x => !(x in cols))
|
||||||
|
@ -236,7 +236,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
formatMove({from, to}) {
|
formatMove({from, to}) {
|
||||||
const value = this.columns[from]
|
const value = this.allColumns[from]
|
||||||
this.settings.tracklist_editor_columns.splice(from, 1)
|
this.settings.tracklist_editor_columns.splice(from, 1)
|
||||||
this.settings.tracklist_editor_columns.splice(to, 0, value)
|
this.settings.tracklist_editor_columns.splice(to, 0, value)
|
||||||
if(this.page == Page.Text)
|
if(this.page == Page.Text)
|
||||||
|
@ -246,9 +246,9 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
columnMove({from, to}) {
|
columnMove({from, to}) {
|
||||||
const value = this.columns[from]
|
const value = this.allColumns[from]
|
||||||
this.columns.splice(from, 1)
|
this.allColumns.splice(from, 1)
|
||||||
this.columns.splice(to, 0, value)
|
this.allColumns.splice(to, 0, value)
|
||||||
this.updateInput()
|
this.updateInput()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -281,10 +281,10 @@ 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 this.allColumns) {
|
||||||
if(col >= lineBits.length)
|
if(col >= lineBits.length)
|
||||||
break
|
break
|
||||||
const attr = this.columns[col]
|
const attr = this.allColumns[col]
|
||||||
item[attr] = lineBits[col].trim()
|
item[attr] = lineBits[col].trim()
|
||||||
}
|
}
|
||||||
item && items.push(item)
|
item && items.push(item)
|
||||||
|
@ -302,7 +302,7 @@ export default {
|
||||||
if(!item)
|
if(!item)
|
||||||
continue
|
continue
|
||||||
var line = []
|
var line = []
|
||||||
for(var col of this.columns)
|
for(var col of this.allColumns)
|
||||||
line.push(item.data[col] || '')
|
line.push(item.data[col] || '')
|
||||||
line = dropRightWhile(line, x => !x || !('' + x).trim())
|
line = dropRightWhile(line, x => !x || !('' + x).trim())
|
||||||
line = line.join(sep).trimRight()
|
line = line.join(sep).trimRight()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user