create EpisodeSound & adapt; update list editors

This commit is contained in:
bkfox 2024-03-26 17:43:04 +01:00
parent bda4efe336
commit 78a8478da8
36 changed files with 784 additions and 728 deletions

View File

@ -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")

View File

@ -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 ""

View File

@ -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."""

View File

@ -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

View File

@ -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,
)

View File

@ -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):

View File

@ -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."""

View File

@ -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",

View File

@ -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

View File

@ -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
View File

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

View File

@ -183,10 +183,14 @@ class BasePage(Renderable, models.Model):
headline[-1] += suffix
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>")

View File

@ -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

View File

@ -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",
)

View File

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

View File

@ -1,23 +1,19 @@
from rest_framework import serializers
from ..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

View File

@ -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:"'" %}

View File

@ -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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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()}))

View File

@ -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:

View File

@ -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):

View File

@ -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,

View File

@ -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."""

View File

@ -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):

View File

@ -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>

View File

@ -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)
},

View File

@ -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>

View File

@ -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() {

View File

@ -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)

View File

@ -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()

View File

@ -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
}
},
}

View 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 }
}