WIP - Sound.file instead of Sound.path; fix issues with player; program.path is now relative

This commit is contained in:
bkfox 2022-03-18 14:12:59 +01:00
parent d17d6831dd
commit e3b744be70
17 changed files with 109 additions and 118 deletions

View File

@ -28,7 +28,7 @@ class SoundInline(admin.TabularInline):
max_num = 0 max_num = 0
def audio(self, obj): def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.url())) return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url))
audio.short_descripton = _('Audio') audio.short_descripton = _('Audio')
def get_queryset(self, request): def get_queryset(self, request):
@ -46,10 +46,10 @@ class SoundAdmin(admin.ModelAdmin):
search_fields = ['name', 'program__title'] search_fields = ['name', 'program__title']
fieldsets = [ fieldsets = [
(None, {'fields': ['name', 'path', 'type', 'program', 'episode']}), (None, {'fields': ['name', 'file', 'type', 'program', 'episode']}),
(None, {'fields': ['duration', 'is_public', 'is_good_quality', 'mtime']}), (None, {'fields': ['duration', 'is_public', 'is_good_quality', 'mtime']}),
] ]
readonly_fields = ('path', 'duration',) readonly_fields = ('file', 'duration',)
inlines = [SoundTrackInline] inlines = [SoundTrackInline]
def related(self, obj): def related(self, obj):
@ -59,7 +59,7 @@ class SoundAdmin(admin.ModelAdmin):
related.short_description = _('Program / Episode') related.short_description = _('Program / Episode')
def audio(self, obj): def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.url())) return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url))
audio.short_descripton = _('Audio') audio.short_descripton = _('Audio')

View File

@ -40,7 +40,7 @@ class AdminSite(admin.AdminSite):
return context return context
def get_urls(self): def get_urls(self):
urls = super().get_urls() + [ urls = [
path('api/', include((self.router.urls, 'api'))), path('api/', include((self.router.urls, 'api'))),
path('tools/statistics/', path('tools/statistics/',
self.admin_view(StatisticsView.as_view()), self.admin_view(StatisticsView.as_view()),
@ -48,7 +48,7 @@ class AdminSite(admin.AdminSite):
path('tools/statistics/<date:date>/', path('tools/statistics/<date:date>/',
self.admin_view(StatisticsView.as_view()), self.admin_view(StatisticsView.as_view()),
name='tools-stats'), name='tools-stats'),
] + self.extra_urls ] + self.extra_urls + super().get_urls()
return urls return urls
def get_tools(self): def get_tools(self):

View File

@ -128,7 +128,7 @@ class Command(BaseCommand):
def handle(self, path, *args, **options): def handle(self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path # FIXME: absolute/relative path of sounds vs given path
if options.get('sound'): if options.get('sound'):
sound = Sound.objects.filter(path__icontains=options.get('sound'))\ sound = Sound.objects.filter(file__icontains=options.get('sound'))\
.first() .first()
else: else:
path_, ext = os.path.splitext(path) path_, ext = os.path.splitext(path)

View File

@ -36,6 +36,7 @@ import mutagen
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
from django.conf import settings as conf
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -64,12 +65,16 @@ class SoundFile:
def __init__(self, path): def __init__(self, path):
self.path = path self.path = path
@property
def sound_path(self):
return self.path.replace(conf.MEDIA_ROOT + '/', '')
def sync(self, sound=None, program=None, deleted=False, **kwargs): def sync(self, sound=None, program=None, deleted=False, **kwargs):
""" """
Update related sound model and save it. Update related sound model and save it.
""" """
if deleted: if deleted:
sound = Sound.objects.filter(path=self.path).first() sound = Sound.objects.filter(file=self.path).first()
if sound: if sound:
sound.type = sound.TYPE_REMOVED sound.type = sound.TYPE_REMOVED
sound.check_on_file() sound.check_on_file()
@ -78,13 +83,13 @@ class SoundFile:
# FIXME: sound.program as not null # FIXME: sound.program as not null
program = kwargs['program'] = Program.get_from_path(self.path) program = kwargs['program'] = Program.get_from_path(self.path)
sound, created = Sound.objects.get_or_create(path=self.path, defaults=kwargs) \ sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs) \
if not sound else (sound, False) if not sound else (sound, False)
self.sound = sound self.sound = sound
sound.program = program sound.program = program
if created or sound.check_on_file(): if created or sound.check_on_file():
logger.info('sound is new or have been modified -> %s', self.path) logger.info('sound is new or have been modified -> %s', self.sound_path)
self.read_path() self.read_path()
sound.name = self.path_info.get('name') sound.name = self.path_info.get('name')
@ -153,7 +158,7 @@ class SoundFile:
if not diffusion: if not diffusion:
return None return None
logger.info('%s <--> %s', self.sound.path, str(diffusion.episode)) logger.info('%s <--> %s', self.sound.file.name, str(diffusion.episode))
self.sound.episode = diffusion.episode self.sound.episode = diffusion.episode
return diffusion return diffusion
@ -172,7 +177,7 @@ class SoundFile:
return return
# import playlist # import playlist
path = os.path.splitext(self.sound.path)[0] + '.csv' path = os.path.splitext(self.sound.file.path)[0] + '.csv'
if os.path.exists(path): if os.path.exists(path):
PlaylistImport(path, sound=sound).run() PlaylistImport(path, sound=sound).run()
# use metadata # use metadata
@ -227,7 +232,7 @@ class MonitorHandler(PatternMatchingEventHandler):
def on_moved(self, event): def on_moved(self, event):
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path) logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
def moved(event, sound_kwargs): def moved(event, sound_kwargs):
sound = Sound.objects.filter(path=event.src_path) sound = Sound.objects.filter(file=event.src_path)
sound_file = SoundFile(event.dest_path) if not sound else sound sound_file = SoundFile(event.dest_path) if not sound else sound
sound_file.sync(**sound_kwargs) sound_file.sync(**sound_kwargs)
self.pool.submit(moved, event, self.sound_kwargs) self.pool.submit(moved, event, self.sound_kwargs)
@ -268,7 +273,8 @@ class Command(BaseCommand):
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type=Sound.TYPE_EXCERPT, type=Sound.TYPE_EXCERPT,
) )
dirs.append(os.path.join(program.path)) dirs.append(os.path.join(program.abspath))
return dirs
def scan_for_program(self, program, subdir, **sound_kwargs): def scan_for_program(self, program, subdir, **sound_kwargs):
""" """
@ -279,7 +285,7 @@ class Command(BaseCommand):
if not program.ensure_dir(subdir): if not program.ensure_dir(subdir):
return return
subdir = os.path.join(program.path, subdir) subdir = os.path.join(program.abspath, subdir)
sounds = [] sounds = []
# sounds in directory # sounds in directory
@ -293,7 +299,7 @@ class Command(BaseCommand):
sounds.append(sound_file.sound.pk) sounds.append(sound_file.sound.pk)
# sounds in db & unchecked # sounds in db & unchecked
sounds = Sound.objects.filter(path__startswith=subdir). \ sounds = Sound.objects.filter(file__startswith=subdir). \
exclude(pk__in=sounds) exclude(pk__in=sounds)
self.check_sounds(sounds, program=program) self.check_sounds(sounds, program=program)
@ -302,7 +308,7 @@ class Command(BaseCommand):
# check files # check files
for sound in qs: for sound in qs:
if sound.check_on_file(): if sound.check_on_file():
SoundFile(sound.path).sync(sound=sound, **sync_kwargs) SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
def monitor(self): def monitor(self):
""" Run in monitor mode """ """ Run in monitor mode """

View File

@ -160,7 +160,7 @@ class Command (BaseCommand):
self.bad = [] self.bad = []
self.good = [] self.good = []
for sound in self.sounds: for sound in self.sounds:
logger.info('analyse ' + sound.path) logger.info('analyse ' + sound.file.name)
sound.analyse() sound.analyse()
sound.check(attr, minmax[0], minmax[1]) sound.check(attr, minmax[0], minmax[1])
if sound.bad: if sound.bad:
@ -171,6 +171,6 @@ class Command (BaseCommand):
# resume # resume
if options.get('resume'): if options.get('resume'):
for sound in self.good: for sound in self.good:
logger.info('\033[92m+ %s\033[0m', sound.path) logger.info('\033[92m+ %s\033[0m', sound.file.name)
for sound in self.bad: for sound in self.bad:
logger.info('\033[91m+ %s\033[0m', sound.path) logger.info('\033[91m+ %s\033[0m', sound.file.name)

View File

@ -7,6 +7,7 @@ import os
import shutil import shutil
import pytz import pytz
from django.conf import settings as conf
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
@ -73,14 +74,17 @@ class Program(Page):
self.slug.replace('-', '_')) self.slug.replace('-', '_'))
@property @property
def archives_path(self): def abspath(self):
return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR) """ Return absolute path to program's dir """
return os.path.join(conf.MEDIA_ROOT, self.path)
@property def archives_path(self, abs=False):
def excerpts_path(self): return os.path.join(abs and self.abspath or self.path,
return os.path.join( settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
) def excerpts_path(self, abs=False):
return os.path.join(abs and self.abspath or self.path,
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
def __init__(self, *kargs, **kwargs): def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs) super().__init__(*kargs, **kwargs)
@ -94,6 +98,8 @@ class Program(Page):
Return a Program from the given path. We assume the path has been Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter). given in a previous time by this model (Program.path getter).
""" """
if path.startswith(conf.MEDIA_ROOT):
path = path.replace(conf.MEDIA_ROOT + '/', '')
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/': while path[0] == '/':
@ -107,10 +113,9 @@ class Program(Page):
Make sur the program's dir exists (and optionally subdir). Return True Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists. if the dir (or subdir) exists.
""" """
path = os.path.join(self.path, subdir) if subdir else \ path = os.path.join(self.abspath, subdir) if subdir else \
self.path self.abspath
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return os.path.exists(path) return os.path.exists(path)
class Meta: class Meta:
@ -127,14 +132,15 @@ class Program(Page):
# TODO: move in signals # TODO: move in signals
path_ = getattr(self, '__initial_path', None) path_ = getattr(self, '__initial_path', None)
abspath = os.path.join(conf.MEDIA_ROOT, path_)
if path_ is not None and path_ != self.path and \ if path_ is not None and path_ != self.path and \
os.path.exists(path_) and not os.path.exists(self.path): os.path.exists(abspath) and not os.path.exists(self.abspath):
logger.info('program #%s\'s dir changed to %s - update it.', logger.info('program #%s\'s dir changed to %s - update it.',
self.id, self.title) self.id, self.title)
shutil.move(path_, self.path) shutil.move(abspath, self.abspath)
Sound.objects.filter(path__startswith=path_) \ Sound.objects.filter(path__startswith=path_) \
.update(path=Concat('path', Substr(F('path'), len(path_)))) .update(file=Concat('file', Substr(F('file'), len(path_))))
class ProgramChildQuerySet(PageQuerySet): class ProgramChildQuerySet(PageQuerySet):

View File

@ -4,7 +4,8 @@ import os
from django.conf import settings as main_settings from django.conf import settings as main_settings
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q, Value as V
from django.db.models.functions import Concat
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -54,7 +55,9 @@ class SoundQuerySet(models.QuerySet):
self = self.archive() self = self.archive()
if order_by: if order_by:
self = self.order_by('path') self = self.order_by('path')
return self.filter(path__isnull=False).values_list('path', flat=True) return self.filter(file__isnull=False) \
.annotate(file_path=Concat(V(conf.MEDIA_ROOT), 'file')) \
.values_list('file_path', flat=True)
def search(self, query): def search(self, query):
return self.filter( return self.filter(
@ -94,21 +97,15 @@ class Sound(models.Model):
position = models.PositiveSmallIntegerField( position = models.PositiveSmallIntegerField(
_('order'), default=0, help_text=_('position in the playlist'), _('order'), default=0, help_text=_('position in the playlist'),
) )
# FIXME: url() does not use the same directory than here
# should we use FileField for more reliability? def _upload_to(self, filename):
path = models.FilePathField( subdir = AIRCOX_SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else \
_('file'), AIRCOX_SOUND_EXCERPTS_SUBDIR
path=settings.AIRCOX_PROGRAMS_DIR, return os.path.join(o.program.path, subdir, filename)
match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
.replace('.', r'\.') + ')$', file = models.FileField(
recursive=True, max_length=255, _('file'), upload_to=_upload_to
blank=True, null=True, unique=True,
) )
#embed = models.TextField(
# _('embed'),
# blank=True, null=True,
# help_text=_('HTML code to embed a sound from an external plateform'),
#)
duration = models.TimeField( duration = models.TimeField(
_('duration'), _('duration'),
blank=True, null=True, blank=True, null=True,
@ -134,8 +131,12 @@ class Sound(models.Model):
verbose_name = _('Sound') verbose_name = _('Sound')
verbose_name_plural = _('Sounds') verbose_name_plural = _('Sounds')
@property
def url(self):
return self.file and self.file.url
def __str__(self): def __str__(self):
return '/'.join(self.path.split('/')[-3:]) return '/'.join(self.file.path.split('/')[-3:])
def save(self, check=True, *args, **kwargs): def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None: if self.episode is not None and self.program is None:
@ -145,17 +146,12 @@ class Sound(models.Model):
self.__check_name() self.__check_name()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def url(self):
""" Return an url to the file. """
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
return (main_settings.MEDIA_URL + path).replace('//','/')
# TODO: rename get_file_mtime(self) # TODO: rename get_file_mtime(self)
def get_mtime(self): def get_mtime(self):
""" """
Get the last modification date from file Get the last modification date from file
""" """
mtime = os.stat(self.path).st_mtime mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime) mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0) mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone()) return tz.make_aware(mtime, tz.get_current_timezone())
@ -163,7 +159,7 @@ class Sound(models.Model):
def file_exists(self): def file_exists(self):
""" Return true if the file still exists. """ """ Return true if the file still exists. """
return os.path.exists(self.path) return os.path.exists(self.file.path)
def check_on_file(self): def check_on_file(self):
""" """
@ -173,7 +169,7 @@ class Sound(models.Model):
if not self.file_exists(): if not self.file_exists():
if self.type == self.TYPE_REMOVED: if self.type == self.TYPE_REMOVED:
return return
logger.info('sound %s: has been removed', self.path) logger.info('sound %s: has been removed', self.file.name)
self.type = self.TYPE_REMOVED self.type = self.TYPE_REMOVED
return True return True
@ -183,7 +179,7 @@ class Sound(models.Model):
if self.type == self.TYPE_REMOVED and self.program: if self.type == self.TYPE_REMOVED and self.program:
changed = True changed = True
self.type = self.TYPE_ARCHIVE \ self.type = self.TYPE_ARCHIVE \
if self.path.startswith(self.program.archives_path) else \ if self.file.path.startswith(self.program.archives_path) else \
self.TYPE_EXCERPT self.TYPE_EXCERPT
# check mtime -> reset quality if changed (assume file changed) # check mtime -> reset quality if changed (assume file changed)
@ -193,15 +189,15 @@ class Sound(models.Model):
self.mtime = mtime self.mtime = mtime
self.is_good_quality = None self.is_good_quality = None
logger.info('sound %s: m_time has changed. Reset quality info', logger.info('sound %s: m_time has changed. Reset quality info',
self.path) self.file.name)
return True return True
return changed return changed
def __check_name(self): def __check_name(self):
if not self.name and self.path: if not self.name and self.file and self.file.path:
# FIXME: later, remove date? # FIXME: later, remove date?
self.name = os.path.basename(self.path) self.name = os.path.basename(self.file.name)
self.name = os.path.splitext(self.name)[0] self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ') self.name = self.name.replace('_', ' ')

View File

@ -55,7 +55,6 @@ class LogInfoSerializer(serializers.Serializer):
class SoundSerializer(serializers.ModelSerializer): class SoundSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html') # serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta: class Meta:
model = Sound model = Sound
fields = ['pk', 'name', 'program', 'episode', 'type', fields = ['pk', 'name', 'program', 'episode', 'type',
@ -64,7 +63,7 @@ class SoundSerializer(serializers.ModelSerializer):
def get_field_names(self, *args): def get_field_names(self, *args):
names = super().get_field_names(*args) names = super().get_field_names(*args)
if 'request' in self.context and self.context['request'].user.is_staff: if 'request' in self.context and self.context['request'].user.is_staff:
names.append('path') names.append('file')
return names return names
class PodcastSerializer(serializers.ModelSerializer): class PodcastSerializer(serializers.ModelSerializer):

View File

@ -87,8 +87,7 @@ ensure('AIRCOX_DEFAULT_USER_GROUPS', {
# Directory for the programs data # Directory for the programs data
# TODO: rename to PROGRAMS_ROOT # TODO: rename to PROGRAMS_ROOT
ensure('AIRCOX_PROGRAMS_DIR', ensure('AIRCOX_PROGRAMS_DIR', 'programs')
os.path.join(settings.MEDIA_ROOT, 'programs'))
######################################################################## ########################################################################
@ -152,8 +151,3 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"') ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
if settings.MEDIA_ROOT not in AIRCOX_PROGRAMS_DIR:
# PROGRAMS_DIR must be in MEDIA_ROOT for easy files url resolution
# later should this restriction disappear.
raise ValueError("settings: AIRCOX_PROGRAMS_DIR must be in MEDIA_ROOT")

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ List item for a podcast.
{% if object.embed %} {% if object.embed %}
{{ object.embed|safe }} {{ object.embed|safe }}
{% else %} {% else %}
<audio src="{{ object.url }}" controls> <audio src="{{ object.file.url }}" controls>
{% endif %} {% endif %}
{% endcomment %} {% endcomment %}
<a-sound-item :data="{{ object|json }}" :player="player" <a-sound-item :data="{{ object|json }}" :player="player"

View File

@ -187,5 +187,5 @@ class QueueSourceViewSet(SourceViewSet):
sound = get_object_or_404(self.get_sound_queryset(), sound = get_object_or_404(self.get_sound_queryset(),
pk=request.data['sound_id']) pk=request.data['sound_id'])
return self._run( return self._run(
pk, lambda s: s.push(sound.path) if sound.path else None) pk, lambda s: s.push(sound.file.path) if sound.file.path else None)

View File

@ -12,6 +12,7 @@ const App = {
export const PlayerApp = { export const PlayerApp = {
el: '#player', el: '#player',
delimiters: ['[[', ']]'],
components: {...components}, components: {...components},
} }

View File

@ -219,7 +219,7 @@ export default {
/// Push items to playlist (by name) /// Push items to playlist (by name)
push(playlist, ...items) { push(playlist, ...items) {
return this.$refs[playlist].push(...items); return this.sets[playlist].push(...items);
}, },
/// Push and play items /// Push and play items

View File

@ -6,7 +6,7 @@
:key="index"> :key="index">
<a :class="index == selectedIndex ? 'is-active' : ''"> <a :class="index == selectedIndex ? 'is-active' : ''">
<ASoundItem <ASoundItem
:data="item" :index="index" :player="player" :set="set" :data="item" :index="index" :set="set" :player="player_"
@togglePlay="togglePlay(index)" @togglePlay="togglePlay(index)"
:actions="actions"> :actions="actions">
<template v-slot:actions="{}"> <template v-slot:actions="{}">
@ -38,7 +38,8 @@ export default {
}, },
computed: { computed: {
self() { return this; } self() { return this; },
player_() { return this.player || window.aircox.player },
}, },
methods: { methods: {
@ -50,8 +51,8 @@ export default {
}, },
togglePlay(index) { togglePlay(index) {
if(this.player.isPlaying(this.set.get(index))) if(this.player_.isPlaying(this.set.get(index)))
this.player.pause(); this.player_.pause();
else else
this.select(index) this.select(index)
}, },

View File

@ -35,6 +35,11 @@ window.aircox = {
init(props=null, {config=null, builder=null, initBuilder=true, init(props=null, {config=null, builder=null, initBuilder=true,
initPlayer=true, hotReload=false}={}) initPlayer=true, hotReload=false}={})
{ {
if(initPlayer) {
let playerBuilder = this.playerBuilder
playerBuilder.mount()
}
if(initBuilder) { if(initBuilder) {
builder = builder || this.builder builder = builder || this.builder
this.builder = builder this.builder = builder
@ -46,11 +51,6 @@ window.aircox = {
if(hotReload) if(hotReload)
builder.enableHotReload(hotReload) builder.enableHotReload(hotReload)
} }
if(initPlayer) {
let playerBuilder = this.playerBuilder
playerBuilder.mount()
}
}, },
} }

View File

@ -1,31 +1,19 @@
asgiref==3.2.10 Django~=3.0
bleach>=3.3.0 djangorestframework~=3.13
Django==3.1.6 django-model-utils>=4.2
django-admin-sortable2==0.7.7 django-filter~=21.1
django-ckeditor==6.0.0
django-content-editor==3.0.6 django-filer~=2.1
django-filer==2.0.2 django-honeypot~=1.0
django-filter==2.3.0 django-taggit~=2.1
django-honeypot==0.9.0 django-admin-sortable2~=1.0
django-js-asset==1.2.2 django-ckeditor~=6.2
django-model-utils==4.0.0 bleach~=4.1
django-mptt==0.11.0 easy-thumbnails~=2.8
django-polymorphic==3.0.0 tzlocal~=4.1
django-taggit==1.3.0
djangorestframework==3.11.2 mutagen~=1.45
easy-thumbnails==2.7 Pillow~=9.0
gunicorn==20.0.4 psutil~=5.9
mutagen==1.45.1
packaging==20.4
pathtools==0.1.2
Pillow==8.1.1
psutil==5.7.2
pyparsing==2.4.7
pytz==2020.1
PyYAML==5.4 PyYAML==5.4
six==1.14.0 watchdog~=2.1
sqlparse==0.3.1
tzlocal==2.1
Unidecode==1.1.1
watchdog==0.10.3
webencodings==0.5.1