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.forms import ModelForm
|
||||
|
||||
from aircox.models import Episode
|
||||
from aircox.models import Episode, EpisodeSound
|
||||
from .page import PageAdmin
|
||||
from .sound import SoundInline, TrackInline
|
||||
from .sound import TrackInline
|
||||
from .diffusion import DiffusionInline
|
||||
|
||||
|
||||
|
@ -25,7 +25,7 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
|
|||
search_fields = PageAdmin.search_fields + ("parent__title",)
|
||||
# readonly_fields = ('parent',)
|
||||
|
||||
inlines = [TrackInline, SoundInline, DiffusionInline]
|
||||
inlines = [TrackInline, DiffusionInline]
|
||||
|
||||
def add_view(self, request, object_id, form_url="", context=None):
|
||||
context = context or {}
|
||||
|
@ -38,3 +38,8 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
|
|||
context["init_app"] = True
|
||||
context["init_el"] = "#inline-tracks"
|
||||
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):
|
||||
model = Sound
|
||||
fields = [
|
||||
"type",
|
||||
"name",
|
||||
"audio",
|
||||
"duration",
|
||||
"broadcast",
|
||||
"is_good_quality",
|
||||
"is_public",
|
||||
"is_downloadable",
|
||||
"is_removed",
|
||||
]
|
||||
readonly_fields = ["type", "audio", "duration", "is_good_quality"]
|
||||
readonly_fields = ["broadcast", "audio", "duration", "is_good_quality"]
|
||||
extra = 0
|
||||
max_num = 0
|
||||
|
||||
|
@ -53,20 +53,20 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||
list_display = [
|
||||
"id",
|
||||
"name",
|
||||
"related",
|
||||
"type",
|
||||
# "related",
|
||||
"broadcast",
|
||||
"duration",
|
||||
"is_public",
|
||||
"is_good_quality",
|
||||
"is_downloadable",
|
||||
"audio",
|
||||
]
|
||||
list_filter = ("type", "is_good_quality", "is_public")
|
||||
list_filter = ("broadcast", "is_good_quality", "is_public")
|
||||
list_editable = ["name", "is_public", "is_downloadable"]
|
||||
|
||||
search_fields = ["name", "program__title"]
|
||||
fieldsets = [
|
||||
(None, {"fields": ["name", "file", "type", "program", "episode"]}),
|
||||
(None, {"fields": ["name", "file", "broadcast", "program", "episode"]}),
|
||||
(
|
||||
None,
|
||||
{
|
||||
|
@ -80,14 +80,16 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||
},
|
||||
),
|
||||
]
|
||||
readonly_fields = ("file", "duration", "type")
|
||||
readonly_fields = ("file", "duration", "is_removed")
|
||||
inlines = [SoundTrackInline]
|
||||
|
||||
def related(self, obj):
|
||||
# TODO: link to episode or program edit
|
||||
return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
|
||||
# # TODO: link to episode or program edit
|
||||
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):
|
||||
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."""
|
||||
|
||||
# --- Sounds
|
||||
SOUND_ARCHIVES_SUBDIR = "archives"
|
||||
SOUND_BROADCASTS_SUBDIR = "archives"
|
||||
"""Sub directory used for the complete episode sounds."""
|
||||
SOUND_EXCERPTS_SUBDIR = "excerpts"
|
||||
"""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).
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
import mutagen
|
||||
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, Track
|
||||
from aircox.models import Program, Sound, EpisodeSound
|
||||
|
||||
from .playlist_import import PlaylistImport
|
||||
|
||||
logger = logging.getLogger("aircox.commands")
|
||||
|
||||
|
||||
__all__ = ("SoundFile",)
|
||||
|
||||
|
||||
class SoundFile:
|
||||
"""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):
|
||||
"""Update related sound model and save it."""
|
||||
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
|
||||
if not program:
|
||||
program = Program.get_from_path(self.path)
|
||||
logger.debug('program from path "%s" -> %s', self.path, program)
|
||||
kwargs["program_id"] = program.pk
|
||||
program = sound and sound.program or Program.get_from_path(self.path)
|
||||
if program:
|
||||
kwargs["program_id"] = program.pk
|
||||
|
||||
if sound:
|
||||
created = False
|
||||
else:
|
||||
created = False
|
||||
if not sound:
|
||||
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs)
|
||||
|
||||
self.sound = sound
|
||||
self.path_info = self.read_path(self.path)
|
||||
|
||||
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.sync_fs(on_update=True, find_playlist=True)
|
||||
sound.save()
|
||||
|
||||
# check for playlist
|
||||
self.find_playlist(sound)
|
||||
if not sound.episodesound_set.all().exists():
|
||||
self.find_episode_sound(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):
|
||||
# TODO: remove from db on delete
|
||||
sound = None
|
||||
if keep_deleted:
|
||||
sound = Sound.objects.path(self.path).first()
|
||||
if sound:
|
||||
if keep_deleted:
|
||||
sound.is_removed = True
|
||||
sound.check_on_file()
|
||||
sound.save()
|
||||
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()
|
||||
if sound := Sound.objects.path(self.path).first():
|
||||
sound.is_removed = True
|
||||
sound.save(sync=False)
|
||||
elif sound := Sound.objects.path(self.path):
|
||||
sound.delete()
|
||||
return sound
|
||||
|
|
|
@ -105,8 +105,7 @@ class MoveTask(Task):
|
|||
def __call__(self, event, **kw):
|
||||
sound = Sound.objects.filter(file=event.src_path).first()
|
||||
if sound:
|
||||
kw["sound"] = sound
|
||||
kw["path"] = event.src_path
|
||||
kw = {**kw, "sound": sound, "path": event.src_path}
|
||||
else:
|
||||
kw["path"] = event.dest_path
|
||||
return super().__call__(event, **kw)
|
||||
|
@ -214,15 +213,15 @@ class SoundMonitor:
|
|||
logger.info(f"#{program.id} {program.title}")
|
||||
self.scan_for_program(
|
||||
program,
|
||||
settings.SOUND_ARCHIVES_SUBDIR,
|
||||
settings.SOUND_BROADCASTS_SUBDIR,
|
||||
logger=logger,
|
||||
type=Sound.TYPE_ARCHIVE,
|
||||
broadcast=True,
|
||||
)
|
||||
self.scan_for_program(
|
||||
program,
|
||||
settings.SOUND_EXCERPTS_SUBDIR,
|
||||
logger=logger,
|
||||
type=Sound.TYPE_EXCERPT,
|
||||
broadcast=False,
|
||||
)
|
||||
dirs.append(program.abspath)
|
||||
return dirs
|
||||
|
@ -255,7 +254,7 @@ class SoundMonitor:
|
|||
"""Only check for the sound existence or update."""
|
||||
# check files
|
||||
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)
|
||||
|
||||
_running = False
|
||||
|
@ -267,15 +266,15 @@ class SoundMonitor:
|
|||
"""Run in monitor mode."""
|
||||
with futures.ThreadPoolExecutor() as pool:
|
||||
archives_handler = MonitorHandler(
|
||||
settings.SOUND_ARCHIVES_SUBDIR,
|
||||
settings.SOUND_BROADCASTS_SUBDIR,
|
||||
pool,
|
||||
type=Sound.TYPE_ARCHIVE,
|
||||
broadcast=True,
|
||||
logger=logger,
|
||||
)
|
||||
excerpts_handler = MonitorHandler(
|
||||
settings.SOUND_EXCERPTS_SUBDIR,
|
||||
pool,
|
||||
type=Sound.TYPE_EXCERPT,
|
||||
broadcast=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
|
|
@ -50,13 +50,13 @@ class ImageFilterSet(filters.FilterSet):
|
|||
class SoundFilterSet(filters.FilterSet):
|
||||
station = filters.NumberFilter(field_name="program__station__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")
|
||||
|
||||
class Meta:
|
||||
model = models.Sound
|
||||
fields = {
|
||||
"episode": ["in", "exact", "isnull"],
|
||||
# "episode": ["in", "exact", "isnull"],
|
||||
}
|
||||
|
||||
def search_filter(self, queryset, name, value):
|
||||
|
|
|
@ -5,7 +5,7 @@ from django.forms.models import modelformset_factory
|
|||
from aircox import models
|
||||
|
||||
|
||||
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "SoundFormSet", "TrackFormSet")
|
||||
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "EpisodeSoundFormSet", "TrackFormSet")
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
|
@ -44,23 +44,12 @@ class EpisodeForm(PageForm):
|
|||
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):
|
||||
"""SoundForm used in EpisodeUpdateView."""
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
|
@ -68,33 +57,39 @@ class SoundCreateForm(forms.ModelForm):
|
|||
|
||||
class Meta:
|
||||
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(
|
||||
models.Track,
|
||||
fields=[
|
||||
"episode",
|
||||
"position",
|
||||
"artist",
|
||||
"title",
|
||||
"tags",
|
||||
],
|
||||
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
)
|
||||
"""Track formset used in EpisodeUpdateView."""
|
||||
|
||||
SoundFormSet = modelformset_factory(
|
||||
models.Sound,
|
||||
fields=[
|
||||
|
||||
EpisodeSoundFormSet = modelformset_factory(
|
||||
models.EpisodeSound,
|
||||
fields=(
|
||||
"episode",
|
||||
"sound",
|
||||
"position",
|
||||
"name",
|
||||
"type",
|
||||
"is_public",
|
||||
"is_downloadable",
|
||||
"duration",
|
||||
],
|
||||
"broadcast",
|
||||
),
|
||||
widgets={
|
||||
"broadcast": forms.CheckboxInput(),
|
||||
"episode": forms.HiddenInput(),
|
||||
"sound": forms.HiddenInput(),
|
||||
"position": forms.HiddenInput(),
|
||||
},
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
)
|
||||
"""Sound formset used in EpisodeUpdateView."""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from . import signals
|
||||
from .article import Article
|
||||
from .diffusion import Diffusion, DiffusionQuerySet
|
||||
from .episode import Episode
|
||||
from .episode import Episode, EpisodeSound
|
||||
from .log import Log, LogQuerySet
|
||||
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
|
||||
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
|
||||
|
@ -14,16 +14,17 @@ from .user_settings import UserSettings
|
|||
__all__ = (
|
||||
"signals",
|
||||
"Article",
|
||||
"Episode",
|
||||
"Category",
|
||||
"Comment",
|
||||
"Diffusion",
|
||||
"DiffusionQuerySet",
|
||||
"Episode",
|
||||
"EpisodeSound",
|
||||
"Log",
|
||||
"LogQuerySet",
|
||||
"Category",
|
||||
"PageQuerySet",
|
||||
"Page",
|
||||
"StaticPage",
|
||||
"Comment",
|
||||
"NavItem",
|
||||
"Program",
|
||||
"ProgramQuerySet",
|
||||
|
|
|
@ -200,31 +200,7 @@ class Diffusion(Rerun):
|
|||
@property
|
||||
def is_live(self):
|
||||
"""True if Diffusion is live (False if there are sounds files)."""
|
||||
return self.type == self.TYPE_ON_AIR and not self.episode.sound_set.archive().count()
|
||||
|
||||
def get_playlist(self, **types):
|
||||
"""Returns sounds as a playlist (list of *local* archive file path).
|
||||
|
||||
The given arguments are passed to ``get_sounds``.
|
||||
"""
|
||||
from .sound import Sound
|
||||
|
||||
return list(
|
||||
self.get_sounds(**types).filter(path__isnull=False, type=Sound.TYPE_ARCHIVE).values_list("path", flat=True)
|
||||
)
|
||||
|
||||
def get_sounds(self, **types):
|
||||
"""Return a queryset of sounds related to this diffusion, ordered by
|
||||
type then path.
|
||||
|
||||
**types: filter on the given sound types name, as `archive=True`
|
||||
"""
|
||||
from .sound import Sound
|
||||
|
||||
sounds = (self.initial or self).sound_set.order_by("type", "path")
|
||||
_in = [getattr(Sound.Type, name) for name, value in types.items() if value]
|
||||
|
||||
return sounds.filter(type__in=_in)
|
||||
return self.type == self.TYPE_ON_AIR and self.episode.episodesound_set.all().broadcast().empty()
|
||||
|
||||
def is_date_in_range(self, date=None):
|
||||
"""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.translation import gettext_lazy as _
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from aircox.conf import settings
|
||||
|
||||
from .page import Page
|
||||
from .program import ProgramChildQuerySet
|
||||
from .sound import Sound
|
||||
|
||||
__all__ = ("Episode",)
|
||||
|
||||
|
||||
class EpisodeQuerySet(ProgramChildQuerySet):
|
||||
def with_podcasts(self):
|
||||
return self.filter(sound__is_public=True).distinct()
|
||||
return self.filter(episodesound__sound__is_public=True).distinct()
|
||||
|
||||
|
||||
class Episode(Page):
|
||||
|
@ -32,39 +35,21 @@ class Episode(Page):
|
|||
@cached_property
|
||||
def podcasts(self):
|
||||
"""Return serialized data about podcasts."""
|
||||
from ..serializers import PodcastSerializer
|
||||
|
||||
query = self.sound_set.public().order_by("type")
|
||||
return self._to_podcasts(query, PodcastSerializer)
|
||||
query = self.episodesound_set.all().public().order_by("-broadcast", "position")
|
||||
return self._to_podcasts(query)
|
||||
|
||||
@cached_property
|
||||
def sounds(self):
|
||||
"""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")
|
||||
return self._to_podcasts(query, SoundSerializer)
|
||||
def _to_podcasts(self, query):
|
||||
from ..serializers import EpisodeSoundSerializer as serializer_class
|
||||
|
||||
def _to_podcasts(self, items, serializer_class):
|
||||
from .sound import Sound
|
||||
|
||||
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
|
||||
query = query.select_related("sound")
|
||||
podcasts = [serializer_class(s).data for s in query]
|
||||
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_title"] = self.title
|
||||
return podcasts
|
||||
|
@ -102,3 +87,55 @@ class Episode(Page):
|
|||
else title
|
||||
)
|
||||
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
|
||||
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
|
||||
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)
|
||||
return content.replace("\n\n", "\n").replace("\n", "<br>")
|
||||
|
||||
|
|
|
@ -1,64 +1,41 @@
|
|||
import logging
|
||||
from datetime import date
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.conf import settings as conf
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from aircox import utils
|
||||
from aircox.conf import settings
|
||||
|
||||
from .episode import Episode
|
||||
from .program import Program
|
||||
|
||||
logger = logging.getLogger("aircox")
|
||||
from .file import File, FileQuerySet
|
||||
from .track import Track
|
||||
from .controllers.playlist_import import PlaylistImport
|
||||
|
||||
|
||||
__all__ = ("Sound", "SoundQuerySet")
|
||||
|
||||
|
||||
class SoundQuerySet(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 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)
|
||||
|
||||
class SoundQuerySet(FileQuerySet):
|
||||
def downloadable(self):
|
||||
"""Return sounds available as podcasts."""
|
||||
return self.filter(is_downloadable=True)
|
||||
|
||||
def archive(self):
|
||||
def broadcast(self):
|
||||
"""Return sounds that are archives."""
|
||||
return self.filter(type=Sound.TYPE_ARCHIVE)
|
||||
return self.filter(broadcast=True)
|
||||
|
||||
def path(self, paths):
|
||||
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):
|
||||
def playlist(self, broadcast=True, order_by=True):
|
||||
"""Return files absolute paths as a flat list (exclude sound without
|
||||
path).
|
||||
|
||||
If `order_by` is True, order by path.
|
||||
"""
|
||||
if archive:
|
||||
self = self.archive()
|
||||
if broadcast:
|
||||
self = self.broadcast()
|
||||
if order_by:
|
||||
self = self.order_by("file")
|
||||
return [
|
||||
|
@ -66,175 +43,147 @@ class SoundQuerySet(models.QuerySet):
|
|||
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)
|
||||
)
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
class Sound(File):
|
||||
duration = models.TimeField(
|
||||
_("duration"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("duration of the sound"),
|
||||
)
|
||||
mtime = models.DateTimeField(
|
||||
_("modification time"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("last modification date and time"),
|
||||
)
|
||||
is_good_quality = models.BooleanField(
|
||||
_("good quality"),
|
||||
help_text=_("sound meets quality requirements"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
is_public = models.BooleanField(
|
||||
_("public"),
|
||||
help_text=_("sound is available as podcast"),
|
||||
default=False,
|
||||
)
|
||||
is_downloadable = models.BooleanField(
|
||||
_("downloadable"),
|
||||
help_text=_("sound can be downloaded by visitors (sound must be public)"),
|
||||
help_text=_("sound can be downloaded by visitors"),
|
||||
default=False,
|
||||
)
|
||||
|
||||
objects = SoundQuerySet.as_manager()
|
||||
broadcast = models.BooleanField(
|
||||
_("Broadcast"),
|
||||
default=False,
|
||||
help_text=_("The sound is broadcasted on air"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Sound")
|
||||
verbose_name_plural = _("Sounds")
|
||||
verbose_name = _("Sound file")
|
||||
verbose_name_plural = _("Sound files")
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.file and self.file.url
|
||||
_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 __str__(self):
|
||||
return "/".join(self.file.path.split("/")[-3:])
|
||||
@classmethod
|
||||
def read_path(cls, path):
|
||||
"""Parse path name returning dictionary of extracted info. It can
|
||||
contain:
|
||||
|
||||
def save(self, check=True, *args, **kwargs):
|
||||
if self.episode is not None and self.program is None:
|
||||
self.program = self.episode.program
|
||||
if check:
|
||||
self.check_on_file()
|
||||
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.
|
||||
- `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)
|
||||
"""
|
||||
if not self.file_exists():
|
||||
if self.is_removed:
|
||||
return
|
||||
logger.debug("sound %s: has been removed", self.file.name)
|
||||
self.is_removed = True
|
||||
return True
|
||||
basename = os.path.basename(path)
|
||||
basename = os.path.splitext(basename)[0]
|
||||
reg_match = cls._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])
|
||||
|
||||
# not anymore removed
|
||||
changed = False
|
||||
name = info.get("name")
|
||||
info["name"] = name and cls._as_name(name) or basename
|
||||
else:
|
||||
info = {"name": basename}
|
||||
return info
|
||||
|
||||
if self.is_removed and self.program:
|
||||
changed = True
|
||||
self.type = (
|
||||
self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT
|
||||
@classmethod
|
||||
def _as_name(cls, name):
|
||||
name = name.replace("_", " ")
|
||||
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"))
|
||||
)
|
||||
|
||||
# check mtime -> reset quality if changed (assume file changed)
|
||||
mtime = self.get_mtime()
|
||||
|
||||
if self.mtime != mtime:
|
||||
self.mtime = mtime
|
||||
self.is_good_quality = None
|
||||
logger.debug(
|
||||
"sound %s: m_time has changed. Reset quality info",
|
||||
self.file.name,
|
||||
title = title or path_noext
|
||||
info = "{} ({})".format(album, year) if album and year else album or year or ""
|
||||
track = Track(
|
||||
sound=self,
|
||||
position=int(meta.tags.get("tracknumber", 0)),
|
||||
title=title,
|
||||
artist=artist or _("unknown"),
|
||||
info=info,
|
||||
)
|
||||
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
|
||||
|
||||
def __check_name(self):
|
||||
if not self.name and self.file and self.file.name:
|
||||
# FIXME: later, remove date?
|
||||
name = os.path.basename(self.file.name)
|
||||
name = os.path.splitext(name)[0]
|
||||
self.name = name.replace("_", " ").strip()
|
||||
def read_metadata(self):
|
||||
import mutagen
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__check_name()
|
||||
meta = mutagen.File(self.file.path)
|
||||
|
||||
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 .log import LogInfo, LogInfoSerializer
|
||||
from .sound import PodcastSerializer, SoundSerializer
|
||||
from .sound import SoundSerializer
|
||||
from .episode import EpisodeSoundSerializer, EpisodeSerializer
|
||||
|
||||
__all__ = (
|
||||
"TrackSerializer",
|
||||
"UserSettingsSerializer",
|
||||
"LogInfo",
|
||||
"LogInfoSerializer",
|
||||
"EpisodeSoundSerializer",
|
||||
"EpisodeSerializer",
|
||||
"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 ..models import Sound
|
||||
from .. import models
|
||||
|
||||
__all__ = ("SoundSerializer", "PodcastSerializer")
|
||||
__all__ = ("SoundSerializer",)
|
||||
|
||||
|
||||
class SoundSerializer(serializers.ModelSerializer):
|
||||
file = serializers.FileField(use_url=False)
|
||||
type_display = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Sound
|
||||
model = models.Sound
|
||||
fields = [
|
||||
"pk",
|
||||
"id",
|
||||
"name",
|
||||
"program",
|
||||
"episode",
|
||||
"type",
|
||||
"type_display",
|
||||
"file",
|
||||
"duration",
|
||||
"mtime",
|
||||
|
@ -26,24 +22,3 @@ class SoundSerializer(serializers.ModelSerializer):
|
|||
"is_downloadable",
|
||||
"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:
|
||||
- tag_id: id of parent component
|
||||
- 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
|
||||
{% endcomment %}
|
||||
|
||||
|
@ -17,9 +19,9 @@ Context:
|
|||
|
||||
<{{ tag }}
|
||||
{% block tag-attrs %}
|
||||
:labels="{% inline_labels %}"
|
||||
:labels="window.aircox.labels"
|
||||
: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" %}"
|
||||
data-prefix="{{ formset.prefix }}-"
|
||||
{% endblock %}>
|
||||
|
@ -29,11 +31,7 @@ Context:
|
|||
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
|
||||
:value="items.length || 0"/>
|
||||
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
|
||||
{% if no_initial_form_count %}
|
||||
:value="items.length || 0"
|
||||
{% else %}
|
||||
value="{{ formset.initial_form_count }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<input type="hidden" name="{{ formset.prefix }}-MIN_NUM_FORMS"
|
||||
value="{{ formset.min_num }}"/>
|
||||
|
@ -51,29 +49,33 @@ Context:
|
|||
</th>
|
||||
{% endblock %}
|
||||
</template>
|
||||
<template v-slot:row-head="{item,row}">
|
||||
{% block row-head %}
|
||||
<template v-slot:row-head="{item,row,attr}">
|
||||
<td>
|
||||
{% block row-head %}
|
||||
[[ row+1 ]]
|
||||
<input type="hidden"
|
||||
:name="'{{ formset.prefix }}-' + row + '-position'"
|
||||
:value="row"/>
|
||||
<input t-if="item.data.id" type="hidden"
|
||||
<input type="hidden"
|
||||
:name="'{{ formset.prefix }}-' + row + '-id'"
|
||||
:value="item.data.id || item.id"/>
|
||||
:value="item.data.id || item.id "/>
|
||||
|
||||
{% for name, field in fields.items %}
|
||||
{% if name != 'position' and field.widget.is_hidden %}
|
||||
{% if name == related_field %}
|
||||
<input type="hidden"
|
||||
: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 %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
</td>
|
||||
{% endblock %}
|
||||
</template>
|
||||
{% 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}">
|
||||
<div class="field">
|
||||
{% with full_name="'"|add:formset.prefix|add:"-' + cell.row + '-"|add:name|add:"'" %}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
{% extends "./list_editor.html" %}
|
||||
{% comment %}
|
||||
Context:
|
||||
- object: episode
|
||||
{% endcomment %}
|
||||
|
||||
{% block outer %}
|
||||
{% with no_initial_form_count=True %}
|
||||
{% with tag_id="inline-sounds" %}
|
||||
{% with tag="a-sound-list-editor" %}
|
||||
{% with related_field="episode" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
@ -13,8 +17,11 @@
|
|||
|
||||
{% block tag-attrs %}
|
||||
{{ 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-delete-url="{% url "api:sound-detail" pk=123 %}"
|
||||
:item-defaults="{episode: {{ object.pk }}}"
|
||||
{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
|
@ -25,7 +32,7 @@ sound-upload-url="{% url "api:sound-list" %}"
|
|||
{% with field.name as name %}
|
||||
{% with field.initial as value %}
|
||||
{% 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 %}
|
||||
{% elif name != "file" %}
|
||||
<div class="field is-horizontal">
|
||||
|
|
|
@ -4,9 +4,11 @@
|
|||
{% block outer %}
|
||||
{% with tag_id="inline-tracks" %}
|
||||
{% with tag="a-track-list-editor" %}
|
||||
{% with related_field="episode" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block inner %}
|
||||
|
|
|
@ -8,10 +8,8 @@
|
|||
<hr/>
|
||||
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset %}
|
||||
<hr/>
|
||||
<section class="container">
|
||||
<h3 class="title">{% translate "Podcasts" %}</h3>
|
||||
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset %}
|
||||
</section>
|
||||
<h3 class="title">{% translate "Podcasts" %}</h3>
|
||||
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset %}
|
||||
</template>
|
||||
</a-episode>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,59 +1,56 @@
|
|||
{% extends "./page_detail.html" %}
|
||||
{% load static i18n %}
|
||||
{% load static aircox_admin i18n %}
|
||||
|
||||
{% block assets %}
|
||||
{{ block.super }}
|
||||
<script src="{% static "aircox/js/dashboard.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block init-scripts %}
|
||||
aircox.labels = {% inline_labels %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block header-cover %}
|
||||
<div class="flex-column">
|
||||
<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" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content-container %}
|
||||
<a-modal ref="cover-modal" title="{% translate "Select an image" %}">
|
||||
<template #default>
|
||||
<a-select-file list-url="{% url "api:image-list" %}" upload-url="{% url "api:image-list" %}"
|
||||
list-class="grid-4"
|
||||
prev-label="{% translate "Show previous" %}"
|
||||
next-label="{% translate "Show next" %}"
|
||||
ref="cover-select"
|
||||
>
|
||||
<template #upload-preview="{upload}">
|
||||
<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>
|
||||
<a-select-file ref="cover-select"
|
||||
:labels="window.aircox.labels"
|
||||
list-url="{% url "api:image-list" %}"
|
||||
upload-url="{% url "api:image-list" %}"
|
||||
delete-url="{% url "api:image-detail" pk=123 %}"
|
||||
title="{% translate "Select an image" %}" list-class="grid-4"
|
||||
@select="(event) => fileSelected('cover-select', 'cover-input', $refs.cover)"
|
||||
>
|
||||
<template #upload-preview="{upload}">
|
||||
<img :src="upload.fileURL" class="upload-preview blink"/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" class="button align-right"
|
||||
@click="(event) => fileSelected('cover-select', 'cover', 'cover-input', 'cover-modal')">
|
||||
{% translate "Select" %}
|
||||
</button>
|
||||
<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-modal>
|
||||
</a-select-file>
|
||||
|
||||
<section class="container">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
|
@ -67,12 +64,12 @@
|
|||
<label class="label">{{ field.label }}</label>
|
||||
<div class="control clear-unset">
|
||||
{% 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" }}"/>
|
||||
{% elif field.name == "content" %}
|
||||
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
|
||||
{% else %}
|
||||
{{ field }}
|
||||
{% include "./dashboard/form_field.html" with field=field.field name=field.name value=field.initial %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="help">{{ field.help_text }}</p>
|
||||
|
|
|
@ -3,6 +3,7 @@ import json
|
|||
from django import template
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from aircox.serializers.admin import UserSettingsSerializer
|
||||
|
||||
|
@ -25,6 +26,7 @@ def do_formset_inline_data(context, formset):
|
|||
- ``items``: list of items. Extra keys:
|
||||
- ``__error__``: dict of form fields errors
|
||||
- ``settings``: user's settings
|
||||
- ``fields``: dict of field name and label
|
||||
"""
|
||||
|
||||
# --- get fields labels
|
||||
|
@ -43,6 +45,9 @@ def do_formset_inline_data(context, formset):
|
|||
# hack for sound list
|
||||
if duration := item.get("duration"):
|
||||
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
|
||||
tags = item.get("tags")
|
||||
|
@ -64,9 +69,15 @@ inline_labels_ = {
|
|||
"remove_item": _("Remove"),
|
||||
"save_settings": _("Save Settings"),
|
||||
"discard_changes": _("Discard changes"),
|
||||
"select_file": _("Select a file"),
|
||||
"submit": _("Submit"),
|
||||
"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
|
||||
"columns": _("Columns"),
|
||||
"timestamp": _("Timestamp"),
|
||||
|
@ -78,4 +89,4 @@ inline_labels_ = {
|
|||
@register.simple_tag(name="inline_labels")
|
||||
def do_inline_labels():
|
||||
"""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.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 ..filters import EpisodeFilters
|
||||
from .page import PageListView
|
||||
|
@ -63,38 +63,39 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
|||
{
|
||||
"prefix": "tracks",
|
||||
"queryset": self.get_tracklist_queryset(episode),
|
||||
"initial": {
|
||||
"episode": episode.id,
|
||||
},
|
||||
"initial": [
|
||||
{
|
||||
"episode": episode.id,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return forms.TrackFormSet(**kwargs)
|
||||
|
||||
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):
|
||||
kwargs.update(
|
||||
{
|
||||
"prefix": "sounds",
|
||||
"queryset": self.get_soundlist_queryset(episode),
|
||||
"initial": {
|
||||
"program": episode.parent_id,
|
||||
"episode": episode.id,
|
||||
},
|
||||
"initial": [
|
||||
{
|
||||
"episode": episode.id,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return forms.SoundFormSet(**kwargs)
|
||||
return forms.EpisodeSoundFormSet(**kwargs)
|
||||
|
||||
def get_sound_form(self, episode, **kwargs):
|
||||
kwargs.update(
|
||||
{
|
||||
"initial": {
|
||||
"program": episode.parent_id,
|
||||
"episode": episode.pk,
|
||||
"name": episode.title,
|
||||
"is_public": True,
|
||||
"type": Sound.TYPE_ARCHIVE,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -122,6 +123,7 @@ class EpisodeUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
|||
for formset in formsets.values():
|
||||
if not formset.is_valid():
|
||||
invalid = True
|
||||
breakpoint()
|
||||
else:
|
||||
formset.save()
|
||||
if invalid:
|
||||
|
|
|
@ -5,8 +5,7 @@ from rest_framework.response import Response
|
|||
|
||||
from filer.models.imagemodels import Image
|
||||
|
||||
from . import models, forms, filters
|
||||
from .serializers import SoundSerializer, admin
|
||||
from . import models, forms, filters, serializers
|
||||
from .views import BaseAPIView
|
||||
|
||||
__all__ = (
|
||||
|
@ -19,7 +18,8 @@ __all__ = (
|
|||
|
||||
class ImageViewSet(viewsets.ModelViewSet):
|
||||
parsers = (parsers.MultiPartParser,)
|
||||
serializer_class = admin.ImageSerializer
|
||||
permissions = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
serializer_class = serializers.admin.ImageSerializer
|
||||
queryset = Image.objects.all().order_by("-uploaded_at")
|
||||
filter_backends = (drf_filters.DjangoFilterBackend,)
|
||||
filterset_class = filters.ImageFilterSet
|
||||
|
@ -37,8 +37,8 @@ class ImageViewSet(viewsets.ModelViewSet):
|
|||
class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
|
||||
parsers = (parsers.MultiPartParser,)
|
||||
permissions = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
serializer_class = SoundSerializer
|
||||
queryset = models.Sound.objects.available().order_by("-pk")
|
||||
serializer_class = serializers.SoundSerializer
|
||||
queryset = models.Sound.objects.order_by("-pk")
|
||||
filter_backends = (drf_filters.DjangoFilterBackend,)
|
||||
filterset_class = filters.SoundFilterSet
|
||||
|
||||
|
@ -48,11 +48,17 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
|
|||
# -> file is saved to fs after object is saved to db
|
||||
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):
|
||||
"""Track viewset used for auto completion."""
|
||||
|
||||
serializer_class = admin.TrackSerializer
|
||||
serializer_class = serializers.admin.TrackSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
filter_backends = (drf_filters.DjangoFilterBackend,)
|
||||
filterset_class = filters.TrackFilterSet
|
||||
|
@ -75,7 +81,7 @@ class UserSettingsViewSet(viewsets.ViewSet):
|
|||
Allow only to create and edit user's own settings.
|
||||
"""
|
||||
|
||||
serializer_class = admin.UserSettingsSerializer
|
||||
serializer_class = serializers.admin.UserSettingsSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get_serializer(self, instance=None, **kwargs):
|
||||
|
|
|
@ -198,7 +198,7 @@ class Monitor:
|
|||
Diffusion.objects.station(self.station)
|
||||
.on_air()
|
||||
.now(now)
|
||||
.filter(episode__sound__type=Sound.TYPE_ARCHIVE)
|
||||
.filter(episode__episodesound__broadcast=True)
|
||||
.first()
|
||||
)
|
||||
# Can't use delay: diffusion may start later than its assigned start.
|
||||
|
@ -227,7 +227,7 @@ class Monitor:
|
|||
return log
|
||||
|
||||
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)
|
||||
self.log(
|
||||
type=Log.TYPE_START,
|
||||
|
|
|
@ -80,7 +80,7 @@ class PlaylistSource(Source):
|
|||
|
||||
def get_sound_queryset(self):
|
||||
"""Get playlist's sounds queryset."""
|
||||
return self.program.sound_set.archive()
|
||||
return self.program.sound_set.broadcast()
|
||||
|
||||
def get_playlist(self):
|
||||
"""Get playlist from db."""
|
||||
|
|
|
@ -137,7 +137,7 @@ class QueueSourceViewSet(SourceViewSet):
|
|||
model = controllers.QueueSource
|
||||
|
||||
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"])
|
||||
def push(self, request, pk):
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<i :class="runIcon"></i>
|
||||
</span>
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
import {getCsrf} from "../model"
|
||||
|
||||
export default {
|
||||
emit: ["fileChange", "load"],
|
||||
emit: ["fileChange", "load", "abort", "error"],
|
||||
|
||||
props: {
|
||||
url: { type: String },
|
||||
|
@ -71,9 +71,9 @@ export default {
|
|||
const req = new XMLHttpRequest()
|
||||
req.open("POST", this.url)
|
||||
req.upload.addEventListener("progress", (e) => this.onUploadProgress(e))
|
||||
req.addEventListener("load", (e) => this.onUploadDone(e))
|
||||
req.addEventListener("abort", (e) => this.onUploadDone(e))
|
||||
req.addEventListener("error", (e) => this.onUploadDone(e))
|
||||
req.addEventListener("load", (e) => this.onUploadDone(e, 'load'))
|
||||
req.addEventListener("abort", (e) => this.onUploadDone(e, 'abort'))
|
||||
req.addEventListener("error", (e) => this.onUploadDone(e, 'error'))
|
||||
|
||||
const formData = new FormData(this.$refs.form);
|
||||
formData.append('csrfmiddlewaretoken', getCsrf())
|
||||
|
@ -87,8 +87,8 @@ export default {
|
|||
this.total = event.total
|
||||
},
|
||||
|
||||
onUploadDone(event) {
|
||||
this.$emit("load", event)
|
||||
onUploadDone(event, eventName) {
|
||||
this.$emit(eventName, event)
|
||||
this._resetUpload(this.STATE.DEFAULT, true)
|
||||
},
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<div class="modal-card-title">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</div>
|
||||
<slot name="bar"></slot>
|
||||
<button type="button" class="delete square" aria-label="close" @click="close">
|
||||
<span class="icon">
|
||||
<i class="fa fa-close"></i>
|
||||
|
|
|
@ -1,63 +1,100 @@
|
|||
<template>
|
||||
<div class="a-select-file">
|
||||
<div ref="list" :class="['a-select-file-list', listClass]">
|
||||
<!-- upload -->
|
||||
<form ref="uploadForm" class="flex-column" v-if="state == STATE.DEFAULT">
|
||||
<div class="field flex-grow-1">
|
||||
<label class="label">{{ uploadLabel }}</label>
|
||||
<input type="file" ref="uploadFile" :name="uploadFieldName" @change="onSubmit"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<slot name="upload-form"></slot>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex-column" v-else>
|
||||
<slot name="upload-preview" :upload="upload"></slot>
|
||||
<div class="flex-row">
|
||||
<progress :max="upload.total" :value="upload.loaded"/>
|
||||
<button type="button" class="button small square ml-2" @click="uploadAbort">
|
||||
<span class="icon small">
|
||||
<i class="fa fa-close"></i>
|
||||
</span>
|
||||
</button>
|
||||
<a-modal ref="modal" :title="title">
|
||||
<template #bar>
|
||||
<button type="button" class="button small mr-3" v-if="panel == LIST"
|
||||
@click="showPanel(UPLOAD)">
|
||||
<span class="icon">
|
||||
<i class="fa fa-upload"></i>
|
||||
</span>
|
||||
<span>{{ labels.upload }}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="button small mr-3" v-else
|
||||
@click="showPanel(LIST)">
|
||||
<span class="icon">
|
||||
<i class="fa fa-list"></i>
|
||||
</span>
|
||||
<span>{{ labels.list }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="a-select-file">
|
||||
<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>
|
||||
|
||||
<!-- tiles -->
|
||||
<div v-if="prevUrl">
|
||||
<a href="#" @click="load(prevUrl)">
|
||||
{{ prevLabel }}
|
||||
</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>
|
||||
</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 #footer>
|
||||
<slot name="footer" :item="item">
|
||||
<span class="mr-3" v-if="item">{{ item.name }}</span>
|
||||
</slot>
|
||||
<button type="button" v-if="panel == LIST" class="button align-right"
|
||||
@click="selected">
|
||||
{{ labels.select_file }}
|
||||
</button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
<script>
|
||||
import {getCsrf} from "../model"
|
||||
import AModal from "./AModal"
|
||||
import AActionButton from "./AActionButton"
|
||||
import AFileUpload from "./AFileUpload"
|
||||
|
||||
export default {
|
||||
emit: ["select"],
|
||||
|
||||
components: {AActionButton, AFileUpload, AModal},
|
||||
|
||||
props: {
|
||||
name: { type: String },
|
||||
title: { type: String },
|
||||
labels: Object,
|
||||
listClass: {type: String, default: ""},
|
||||
prevLabel: { type: String, default: "Prev" },
|
||||
nextLabel: { type: String, default: "Next" },
|
||||
|
||||
// List url
|
||||
listUrl: { type: String },
|
||||
|
||||
// URL to delete an item, where "123" is replaced by
|
||||
// the item id.
|
||||
deleteUrl: {type: String },
|
||||
|
||||
uploadUrl: { type: String },
|
||||
uploadFieldName: { type: String, default: "file" },
|
||||
uploadLabel: { type: String, default: "Upload a file" },
|
||||
|
@ -65,91 +102,63 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
STATE: {
|
||||
DEFAULT: 0,
|
||||
UPLOADING: 1,
|
||||
},
|
||||
|
||||
state: 0,
|
||||
LIST: 0,
|
||||
UPLOAD: 1,
|
||||
|
||||
panel: 0,
|
||||
item: null,
|
||||
items: [],
|
||||
nextUrl: "",
|
||||
prevUrl: "",
|
||||
lastUrl: "",
|
||||
|
||||
upload: {},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.modal.open()
|
||||
},
|
||||
|
||||
close() {
|
||||
this.$refs.modal.close()
|
||||
},
|
||||
|
||||
showPanel(panel) {
|
||||
this.panel = panel
|
||||
},
|
||||
|
||||
load(url) {
|
||||
fetch(url || this.listUrl).then(
|
||||
return fetch(url || this.listUrl).then(
|
||||
response => response.ok ? response.json() : Promise.reject(response)
|
||||
).then(data => {
|
||||
this.lastUrl = url
|
||||
this.nextUrl = data.next
|
||||
this.prevUrl = data.previous
|
||||
this.items = data.results
|
||||
this.showPanel(this.LIST)
|
||||
|
||||
this.$forceUpdate()
|
||||
this.$refs.list.scroll(0, 0)
|
||||
return this.items
|
||||
})
|
||||
},
|
||||
|
||||
//! Select an item
|
||||
select(item) {
|
||||
this.item = item;
|
||||
},
|
||||
|
||||
// ---- upload
|
||||
uploadAbort() {
|
||||
this.upload.request && this.upload.request.abort()
|
||||
//! User click on select button (confirm selection)
|
||||
selected() {
|
||||
this.$emit("select", this.item)
|
||||
this.close()
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
const [file] = this.$refs.uploadFile.files
|
||||
if(!file)
|
||||
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)
|
||||
uploadDone(reload=false) {
|
||||
reload && this.load().then(items => {
|
||||
this.item = items[0]
|
||||
})
|
||||
},
|
||||
|
||||
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() {
|
||||
|
|
|
@ -1,27 +1,25 @@
|
|||
<template>
|
||||
<div class="a-playlist-editor">
|
||||
<a-modal ref="modal" :title="labels && labels.add_sound">
|
||||
<template #default>
|
||||
<a-file-upload ref="file-upload" :url="soundUploadUrl" :label="labels.select_file" submitLabel="" @load="uploadDone"
|
||||
>
|
||||
<template #preview="{upload}">
|
||||
<slot name="upload-preview" :upload="upload"></slot>
|
||||
</template>
|
||||
<template #form>
|
||||
<slot name="upload-form"></slot>
|
||||
</template>
|
||||
</a-file-upload>
|
||||
<a-select-file ref="select-file"
|
||||
:title="labels && labels.add_sound"
|
||||
:labels="labels"
|
||||
:list-url="soundListUrl"
|
||||
:deleteUrl="soundDeleteUrl"
|
||||
:uploadUrl="soundUploadUrl"
|
||||
:uploadLabel="labels.select_file"
|
||||
@select="selected"
|
||||
>
|
||||
<template #upload-preview="{upload}">
|
||||
<slot name="upload-preview" :upload="upload"></slot>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" class="button"
|
||||
@click.stop="$refs['file-upload'].submit()">
|
||||
<span class="icon">
|
||||
<i class="fa fa-upload"></i>
|
||||
</span>
|
||||
<span>{{ labels.submit }}</span>
|
||||
</button>
|
||||
<template #upload-form>
|
||||
<slot name="upload-form"></slot>
|
||||
</template>
|
||||
</a-modal>
|
||||
<template #default="{item}">
|
||||
<audio controls :src="item.url"></audio>
|
||||
<label class="label small flex-grow-1">{{ item.name }}</label>
|
||||
</template>
|
||||
</a-select-file>
|
||||
|
||||
<slot name="top" :set="set" :items="set.items"></slot>
|
||||
<a-rows :set="set" :columns="allColumns"
|
||||
|
@ -31,6 +29,11 @@
|
|||
v-slot:[slot]="data">
|
||||
<slot v-if="name != 'row-tail'" :name="name" v-bind="data"/>
|
||||
</template>
|
||||
|
||||
<template #row-sound="{item}">
|
||||
<label>{{ item.data.name }}</label><br>
|
||||
<audio controls :src="item.data.url"/>
|
||||
</template>
|
||||
</a-rows>
|
||||
|
||||
<div class="flex-row">
|
||||
|
@ -45,7 +48,7 @@
|
|||
<span class="icon"><i class="fa fa-rotate" /></span>
|
||||
</button>
|
||||
<button type="button" class="button square is-primary p-2"
|
||||
@click="$refs.modal.open()"
|
||||
@click="$refs['select-file'].open()"
|
||||
:title="labels.add_sound"
|
||||
:aria-label="labels.add_sound"
|
||||
>
|
||||
|
@ -61,25 +64,27 @@
|
|||
import {cloneDeep} from 'lodash'
|
||||
import Model, {Set} from '../model'
|
||||
|
||||
// import AActionButton from './AActionButton'
|
||||
import ARows from './ARows'
|
||||
import AModal from "./AModal"
|
||||
import AFileUpload from "./AFileUpload"
|
||||
//import AFileUpload from "./AFileUpload"
|
||||
import ASelectFile from "./ASelectFile"
|
||||
|
||||
export default {
|
||||
components: {ARows, AModal, AFileUpload},
|
||||
components: {ARows, ASelectFile},
|
||||
|
||||
props: {
|
||||
// default values of items
|
||||
itemDefaults: Object,
|
||||
// initial datas
|
||||
initData: Object,
|
||||
dataPrefix: String,
|
||||
labels: Object,
|
||||
settingsUrl: String,
|
||||
|
||||
soundListUrl: String,
|
||||
soundUploadUrl: String,
|
||||
player: Object,
|
||||
soundDeleteUrl: String,
|
||||
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => ['name', "type", 'is_public', 'is_downloadable']
|
||||
default: () => ['name', "broadcast"]
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -95,7 +100,7 @@ export default {
|
|||
},
|
||||
|
||||
allColumns() {
|
||||
return [...this.columns, "delete"]
|
||||
return ["sound", ...this.columns, "delete"]
|
||||
},
|
||||
|
||||
allColumnsLabels() {
|
||||
|
@ -131,17 +136,18 @@ export default {
|
|||
// this.settingsSaved(settings)
|
||||
},
|
||||
|
||||
uploadDone(event) {
|
||||
const req = event.target
|
||||
if(req.status == 201) {
|
||||
const item = JSON.parse(req.response)
|
||||
this.set.push(item)
|
||||
this.$refs.modal.close()
|
||||
selected(item) {
|
||||
const data = {
|
||||
...this.itemDefaults,
|
||||
"sound": item.id,
|
||||
"name": item.name,
|
||||
"url": item.url,
|
||||
"broadcast": item.broadcast,
|
||||
}
|
||||
this.set.push(data)
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
watch: {
|
||||
initData(val) {
|
||||
this.loadData(val)
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
</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">
|
||||
<textarea ref="textarea" class="is-fullwidth is-size-6" rows="20"
|
||||
@change="updateList"
|
||||
|
@ -35,7 +35,7 @@
|
|||
|
||||
</section>
|
||||
<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"
|
||||
@cell="onCellEvent">
|
||||
<template v-for="[name,slot] of rowsSlots" :key="slot"
|
||||
|
@ -98,10 +98,10 @@
|
|||
<table class="table is-bordered"
|
||||
style="vertical-align: middle">
|
||||
<tr>
|
||||
<a-row :columns="columns" :item="initData.fields"
|
||||
<a-row :columns="allColumns" :item="initData.fields"
|
||||
@move="formatMove" :orderable="true">
|
||||
<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})"
|
||||
><i class="fa fa-left-right"/>
|
||||
</span>
|
||||
|
@ -143,7 +143,7 @@
|
|||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
<slot name="bottom" :set="set" :columns="columns" :items="items"/>
|
||||
<slot name="bottom" :set="set" :columns="allColumns" :items="items"/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -175,7 +175,7 @@ export default {
|
|||
|
||||
data() {
|
||||
const settings = {
|
||||
tracklist_editor_columns: this.defaultColumns,
|
||||
tracklist_editor_columns: this.columns,
|
||||
tracklist_editor_sep: ' -- ',
|
||||
}
|
||||
return {
|
||||
|
@ -204,7 +204,7 @@ export default {
|
|||
get() { return this.settings.tracklist_editor_sep }
|
||||
},
|
||||
|
||||
columns: {
|
||||
allColumns: {
|
||||
set(value) {
|
||||
var cols = value.filter(x => x in this.defaultColumns)
|
||||
var left = this.defaultColumns.filter(x => !(x in cols))
|
||||
|
@ -236,7 +236,7 @@ export default {
|
|||
},
|
||||
|
||||
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(to, 0, value)
|
||||
if(this.page == Page.Text)
|
||||
|
@ -246,9 +246,9 @@ export default {
|
|||
},
|
||||
|
||||
columnMove({from, to}) {
|
||||
const value = this.columns[from]
|
||||
this.columns.splice(from, 1)
|
||||
this.columns.splice(to, 0, value)
|
||||
const value = this.allColumns[from]
|
||||
this.allColumns.splice(from, 1)
|
||||
this.allColumns.splice(to, 0, value)
|
||||
this.updateInput()
|
||||
},
|
||||
|
||||
|
@ -281,10 +281,10 @@ export default {
|
|||
|
||||
var lineBits = line.split(this.separator)
|
||||
var item = {}
|
||||
for(var col in this.columns) {
|
||||
for(var col in this.allColumns) {
|
||||
if(col >= lineBits.length)
|
||||
break
|
||||
const attr = this.columns[col]
|
||||
const attr = this.allColumns[col]
|
||||
item[attr] = lineBits[col].trim()
|
||||
}
|
||||
item && items.push(item)
|
||||
|
@ -302,7 +302,7 @@ export default {
|
|||
if(!item)
|
||||
continue
|
||||
var line = []
|
||||
for(var col of this.columns)
|
||||
for(var col of this.allColumns)
|
||||
line.push(item.data[col] || '')
|
||||
line = dropRightWhile(line, x => !x || !('' + x).trim())
|
||||
line = line.join(sep).trimRight()
|
||||
|
|
|
@ -17,13 +17,12 @@ const DashboardApp = {
|
|||
methods: {
|
||||
...App.methods,
|
||||
|
||||
fileSelected(select, cover, input, modal) {
|
||||
console.log("file!")
|
||||
fileSelected(select, input, preview) {
|
||||
const item = this.$refs[select].item
|
||||
if(item) {
|
||||
this.$refs[cover].src = item.file
|
||||
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 {
|
||||
constructor({sound={}, ...data}={}, options={}) {
|
||||
// flatten EpisodeSound and sound data
|
||||
super({...sound, ...data}, options)
|
||||
}
|
||||
|
||||
get name() { return this.data.name }
|
||||
get src() { return this.data.url }
|
||||
|
||||
static getId(data) { return data.pk }
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user