@ -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)
 | 
			
		||||
        program = sound and sound.program or Program.get_from_path(self.path)
 | 
			
		||||
        if program:
 | 
			
		||||
            kwargs["program_id"] = program.pk
 | 
			
		||||
 | 
			
		||||
        if sound:
 | 
			
		||||
        created = False
 | 
			
		||||
        else:
 | 
			
		||||
        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
 | 
			
		||||
        if keep_deleted:
 | 
			
		||||
            sound = Sound.objects.path(self.path).first()
 | 
			
		||||
            if sound:
 | 
			
		||||
        sound = None
 | 
			
		||||
        if keep_deleted:
 | 
			
		||||
            if sound := Sound.objects.path(self.path).first():
 | 
			
		||||
                sound.is_removed = True
 | 
			
		||||
                    sound.check_on_file()
 | 
			
		||||
                    sound.save()
 | 
			
		||||
                sound.save(sync=False)
 | 
			
		||||
        elif sound := Sound.objects.path(self.path):
 | 
			
		||||
            sound.delete()
 | 
			
		||||
        return sound
 | 
			
		||||
        else:
 | 
			
		||||
            Sound.objects.path(self.path).delete()
 | 
			
		||||
 | 
			
		||||
    def read_path(self, path):
 | 
			
		||||
        """Parse path name returning dictionary of extracted info. It can
 | 
			
		||||
        contain:
 | 
			
		||||
 | 
			
		||||
        - `year`, `month`, `day`: diffusion date
 | 
			
		||||
        - `hour`, `minute`: diffusion time
 | 
			
		||||
        - `n`: sound arbitrary number (used for sound ordering)
 | 
			
		||||
        - `name`: cleaned name extracted or file name (without extension)
 | 
			
		||||
        """
 | 
			
		||||
        basename = os.path.basename(path)
 | 
			
		||||
        basename = os.path.splitext(basename)[0]
 | 
			
		||||
        reg_match = self._path_re.search(basename)
 | 
			
		||||
        if reg_match:
 | 
			
		||||
            info = reg_match.groupdict()
 | 
			
		||||
            for k in ("year", "month", "day", "hour", "minute", "n"):
 | 
			
		||||
                if info.get(k) is not None:
 | 
			
		||||
                    info[k] = int(info[k])
 | 
			
		||||
 | 
			
		||||
            name = info.get("name")
 | 
			
		||||
            info["name"] = name and self._into_name(name) or basename
 | 
			
		||||
        else:
 | 
			
		||||
            info = {"name": basename}
 | 
			
		||||
        return info
 | 
			
		||||
 | 
			
		||||
    _path_re = re.compile(
 | 
			
		||||
        "^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
 | 
			
		||||
        "(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
 | 
			
		||||
        "(_(?P<n>[0-9]+))?"
 | 
			
		||||
        "_?[ -]*(?P<name>.*)$"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def _into_name(self, name):
 | 
			
		||||
        name = name.replace("_", " ")
 | 
			
		||||
        return " ".join(r.capitalize() for r in name.split(" "))
 | 
			
		||||
 | 
			
		||||
    def read_file_info(self):
 | 
			
		||||
        """Read file information and metadata."""
 | 
			
		||||
        try:
 | 
			
		||||
            if os.path.exists(self.path):
 | 
			
		||||
                return mutagen.File(self.path)
 | 
			
		||||
        except Exception:
 | 
			
		||||
            pass
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def find_episode(self, sound, path_info):
 | 
			
		||||
        """For a given program, check if there is an initial diffusion to
 | 
			
		||||
        associate to, using the date info we have. Update self.sound and save
 | 
			
		||||
        it consequently.
 | 
			
		||||
 | 
			
		||||
        We only allow initial diffusion since there should be no rerun.
 | 
			
		||||
        """
 | 
			
		||||
        program, pi = sound.program, path_info
 | 
			
		||||
        if "year" not in pi or not sound or sound.episode:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
 | 
			
		||||
        if pi.get("hour") is not None:
 | 
			
		||||
            at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
 | 
			
		||||
            at = tz.make_aware(at)
 | 
			
		||||
        else:
 | 
			
		||||
            at = date(year, month, day)
 | 
			
		||||
 | 
			
		||||
        diffusion = program.diffusion_set.at(at).first()
 | 
			
		||||
        if not diffusion:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        logger.debug("%s <--> %s", sound.file.name, str(diffusion.episode))
 | 
			
		||||
        return diffusion.episode
 | 
			
		||||
 | 
			
		||||
    def find_playlist(self, sound=None, use_meta=True):
 | 
			
		||||
        """Find a playlist file corresponding to the sound path, such as:
 | 
			
		||||
        my_sound.ogg => my_sound.csv.
 | 
			
		||||
 | 
			
		||||
        Use sound's file metadata if no corresponding playlist has been
 | 
			
		||||
        found and `use_meta` is True.
 | 
			
		||||
        """
 | 
			
		||||
        if sound is None:
 | 
			
		||||
            sound = self.sound
 | 
			
		||||
        if sound.track_set.count() > 1:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # import playlist
 | 
			
		||||
        path_noext, ext = os.path.splitext(self.sound.file.path)
 | 
			
		||||
        path = path_noext + ".csv"
 | 
			
		||||
        if os.path.exists(path):
 | 
			
		||||
            PlaylistImport(path, sound=sound).run()
 | 
			
		||||
        # use metadata
 | 
			
		||||
        elif use_meta:
 | 
			
		||||
            if self.info is None:
 | 
			
		||||
                self.read_file_info()
 | 
			
		||||
            if self.info and self.info.tags:
 | 
			
		||||
                tags = self.info.tags
 | 
			
		||||
                title, artist, album, year = tuple(
 | 
			
		||||
                    t and ", ".join(t) for t in (tags.get(k) for k in ("title", "artist", "album", "year"))
 | 
			
		||||
                )
 | 
			
		||||
                title = title or (self.path_info and self.path_info.get("name")) or os.path.basename(path_noext)
 | 
			
		||||
                info = "{} ({})".format(album, year) if album and year else album or year or ""
 | 
			
		||||
                track = Track(
 | 
			
		||||
                    sound=sound,
 | 
			
		||||
                    position=int(tags.get("tracknumber", 0)),
 | 
			
		||||
                    title=title,
 | 
			
		||||
                    artist=artist or _("unknown"),
 | 
			
		||||
                    info=info,
 | 
			
		||||
                )
 | 
			
		||||
                track.save()
 | 
			
		||||
 | 
			
		||||
@ -105,8 +105,7 @@ class MoveTask(Task):
 | 
			
		||||
    def __call__(self, event, **kw):
 | 
			
		||||
        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:
 | 
			
		||||
        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])
 | 
			
		||||
 | 
			
		||||
            name = info.get("name")
 | 
			
		||||
            info["name"] = name and cls._as_name(name) or basename
 | 
			
		||||
        else:
 | 
			
		||||
            info = {"name": basename}
 | 
			
		||||
        return info
 | 
			
		||||
 | 
			
		||||
    @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
 | 
			
		||||
            logger.debug("sound %s: has been removed", self.file.name)
 | 
			
		||||
            self.is_removed = True
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        # not anymore removed
 | 
			
		||||
        changed = False
 | 
			
		||||
        # import playlist
 | 
			
		||||
        path_noext, ext = os.path.splitext(self.file.path)
 | 
			
		||||
        path = path_noext + ".csv"
 | 
			
		||||
        if os.path.exists(path):
 | 
			
		||||
            PlaylistImport(path, sound=self).run()
 | 
			
		||||
        # use metadata
 | 
			
		||||
        elif meta and meta.tags:
 | 
			
		||||
            title, artist, album, year = tuple(
 | 
			
		||||
                t and ", ".join(t) for t in (meta.tags.get(k) for k in ("title", "artist", "album", "year"))
 | 
			
		||||
            )
 | 
			
		||||
            title = title or path_noext
 | 
			
		||||
            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,
 | 
			
		||||
            )
 | 
			
		||||
            track.save()
 | 
			
		||||
 | 
			
		||||
        if self.is_removed and self.program:
 | 
			
		||||
    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
 | 
			
		||||
            self.type = (
 | 
			
		||||
                self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # 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,
 | 
			
		||||
            )
 | 
			
		||||
            return 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 %}
 | 
			
		||||
            </td>
 | 
			
		||||
                {% endblock %}
 | 
			
		||||
            </td>
 | 
			
		||||
        </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>
 | 
			
		||||
    </template>
 | 
			
		||||
</a-episode>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -1,28 +1,33 @@
 | 
			
		||||
{% 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"
 | 
			
		||||
<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"/>
 | 
			
		||||
@ -45,15 +50,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </template>
 | 
			
		||||
        </a-select-file>
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #footer>
 | 
			
		||||
        <button type="button" class="button align-right"
 | 
			
		||||
            @click="(event) => fileSelected('cover-select', 'cover', 'cover-input', 'cover-modal')">
 | 
			
		||||
            {% translate "Select" %}
 | 
			
		||||
        </button>
 | 
			
		||||
    </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": {
 | 
			
		||||
                "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,
 | 
			
		||||
                "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>
 | 
			
		||||
    <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>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <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)">
 | 
			
		||||
                    {{ prevLabel }}
 | 
			
		||||
                            {{ 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)">
 | 
			
		||||
                    {{ nextLabel }}
 | 
			
		||||
                            {{ labels.show_next }}
 | 
			
		||||
                        </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"
 | 
			
		||||
        <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 #preview="{upload}">
 | 
			
		||||
            <template #upload-preview="{upload}">
 | 
			
		||||
                <slot name="upload-preview" :upload="upload"></slot>
 | 
			
		||||
            </template>
 | 
			
		||||
                    <template #form>
 | 
			
		||||
            <template #upload-form>
 | 
			
		||||
                <slot name="upload-form"></slot>
 | 
			
		||||
            </template>
 | 
			
		||||
                </a-file-upload>
 | 
			
		||||
            <template #default="{item}">
 | 
			
		||||
                <audio controls :src="item.url"></audio>
 | 
			
		||||
                <label class="label small flex-grow-1">{{ item.name }}</label>
 | 
			
		||||
            </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>
 | 
			
		||||
        </a-modal>
 | 
			
		||||
        </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 }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user