11 from django.conf import settings
12 from django.core.exceptions import PermissionDenied
13 from django.core.files.storage import default_storage
14 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
15 from django.core.urlresolvers import reverse, reverse_lazy
16 from django.contrib import messages
17 from django.db.models import Q, Sum
18 from django.http import HttpResponse, HttpResponseRedirect, FileResponse, JsonResponse, Http404
19 from django.utils.six import StringIO
20 from django.utils.translation import ugettext_lazy as _
21 from django.views.generic.base import RedirectView, TemplateView
22 from django.views.generic.dates import DayArchiveView
23 from django.views.generic.detail import DetailView
24 from django.views.generic.edit import CreateView, FormView, UpdateView
25 from django.views.generic.list import ListView
27 from .forms import UploadTracksForm, TrackMetaForm, TrackSearchForm, CleanupForm, ZoneSettingsForm
28 from .models import (SomaLogLine, Track, Artist, NonstopFile,
29 ScheduledDiffusion, Jingle, Stream, NonstopZoneSettings)
30 from emissions.models import Nonstop, Diffusion
31 from emissions.utils import period_program
34 from .app_settings import app_settings
37 class SomaDayArchiveView(DayArchiveView):
38 queryset = SomaLogLine.objects.all()
39 date_field = "play_timestamp"
40 make_object_list = True
45 class SomaDayArchiveCsvView(SomaDayArchiveView):
46 def render_to_response(self, context, **response_kwargs):
48 writer = csv.writer(out)
49 for line in context['object_list']:
50 if line.filepath.track:
51 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
53 line.filepath.track.title,
54 line.filepath.track.artist.name,
55 line.filepath.track.language,
56 line.filepath.track.instru and 'instru' or '',
57 line.filepath.track.cfwb and 'cfwb' or '',
58 line.filepath.track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if line.filepath.track.added_to_nonstop_timestamp else '',
61 writer.writerow([line.play_timestamp.strftime('%Y-%m-%d %H:%M'),
63 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
66 class RedirectTodayView(RedirectView):
67 def get_redirect_url(self, *args, **kwargs):
68 today = datetime.datetime.today()
69 return reverse('archive_day', kwargs={
75 class TrackDetailView(DetailView):
78 def get_context_data(self, **kwargs):
79 ctx = super(TrackDetailView, self).get_context_data(**kwargs)
80 ctx['metadata_form'] = TrackMetaForm(instance=self.object)
83 def post(self, request, *args, **kwargs):
84 assert self.request.user.has_perm('nonstop.add_track')
85 instance = self.get_object()
86 old_nonstop_zones = copy.copy(instance.nonstop_zones.all())
87 form = TrackMetaForm(request.POST, instance=instance)
89 new_nonstop_zones = self.get_object().nonstop_zones.all()
90 if set(old_nonstop_zones) != set(new_nonstop_zones):
91 instance.sync_nonstop_zones()
92 return HttpResponseRedirect('.')
95 def track_sound(request, pk):
97 track = Track.objects.get(id=pk)
98 except Track.DoesNotExist:
100 if not track.file_exists():
102 file_path = track.file_path()
103 remote_ip = (request.META.get('HTTP_X_FORWARDED_FOR') or
104 request.META.get('HTTP_X_REAL_IP') or
105 request.META.get('REMOTE_ADDR'))
106 if remote_ip in settings.INTERNAL_IPS:
108 return FileResponse(open(file_path, 'rb'))
109 # remote user, transcode and serve first minute
112 '-loglevel', 'quiet',
113 '-t', '60', # 60 seconds
118 '-', # send to stdout
120 if track.duration and track.duration.total_seconds() > 60:
121 cmdline[1:1] = ['-ss', '60']
122 cmd = subprocess.run(cmdline, capture_output=True)
123 return HttpResponse(cmd.stdout, content_type='audio/opus')
126 class ArtistDetailView(DetailView):
130 class ArtistListView(ListView):
134 class ZonesView(ListView):
136 template_name = 'nonstop/zones.html'
138 def get_queryset(self):
139 return sorted(super().get_queryset(), key=lambda x: datetime.time(23, 59) if (x.start == x.end) else x.start)
142 class ZoneStats(object):
143 def __init__(self, zone, from_date=None, until_date=None, **kwargs):
145 self.qs = Track.objects.filter(nonstop_zones=self.zone, **kwargs)
146 self.from_date = from_date
148 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__gte=from_date)
150 self.qs = self.qs.filter(nonstopfile__somalogline__play_timestamp__lte=until_date)
151 self.qs = self.qs.distinct()
153 def total_duration(self, **kwargs):
155 total = self.qs.filter(**kwargs).aggregate(Sum('duration'))['duration__sum'].total_seconds()
156 except AttributeError:
157 # 'NoneType' object has no attribute 'total_seconds', if there's no
161 duration = _('%d hours') % (total / 3600)
163 duration = _('%d minutes') % (total / 60)
164 start = datetime.datetime(2000, 1, 1, self.zone.start.hour, self.zone.start.minute)
165 end = datetime.datetime(2000, 1, 1, self.zone.end.hour, self.zone.end.minute)
167 end = end + datetime.timedelta(days=1)
168 return duration + _(', → %d days') % (total // (end - start).total_seconds())
170 def count(self, **kwargs):
171 return self.qs.filter(**kwargs).count()
173 def percentage(self, **kwargs):
177 return '%.2f%%' % (100. * self.count(**kwargs) / total)
180 return self.count(instru=True)
182 def instru_percentage(self):
183 return self.percentage(instru=True)
186 return self.count(sabam=True)
188 def sabam_percentage(self):
189 return self.percentage(sabam=True)
192 return self.count(cfwb=True)
194 def cfwb_percentage(self):
195 return self.percentage(cfwb=True)
197 def language_set(self):
198 return self.count() - self.language_unset()
200 def language_unset(self):
201 return self.count(language='')
203 def unset_language_percentage(self):
204 return self.percentage(language='')
207 return self.count(language='fr')
209 def unset_or_na_language(self):
210 return self.qs.filter(Q(language='') | Q(language='na')).count()
212 def french_percentage(self):
213 considered_tracks = self.count() - self.unset_or_na_language()
214 if considered_tracks == 0:
216 return '%.2f%%' % (100. * self.french() / considered_tracks)
218 def quota_french(self):
219 # obligation de diffuser annuellement au moins 30% d'œuvres musicales de
221 considered_tracks = self.count() - self.unset_or_na_language()
222 if considered_tracks == 0:
224 return (100. * self.french() / considered_tracks) > 30.
226 def quota_cfwb(self):
227 # obligation de diffuser annuellement au moins 4,5% d'œuvres musicales
228 # émanant de la Communauté française
229 considered_tracks = self.count()
230 if considered_tracks == 0:
232 return (100. * self.cfwb() / considered_tracks) > 4.5
235 return self.count(nonstopfile__creation_timestamp__gte=self.from_date)
237 def percent_new_files(self):
238 return self.percentage(nonstopfile__creation_timestamp__gte=self.from_date)
241 def parse_date(date):
242 if date.endswith('d'):
243 return datetime.datetime.today() + datetime.timedelta(int(date.rstrip('d')))
244 return datetime.datetime.strptime(date, '%Y-%m-%d').date()
247 class StatisticsView(TemplateView):
248 template_name = 'nonstop/statistics.html'
250 def get_context_data(self, **kwargs):
251 context = super(StatisticsView, self).get_context_data(**kwargs)
252 context['zones'] = [x for x in Nonstop.objects.all().order_by('start') if x.start != x.end]
254 if 'from' in self.request.GET:
255 kwargs['from_date'] = parse_date(self.request.GET['from'])
256 context['from_date'] = kwargs['from_date']
257 if 'until' in self.request.GET:
258 kwargs['until_date'] = parse_date(self.request.GET['until'])
259 if 'onair' in self.request.GET:
260 kwargs['nonstopfile__somalogline__on_air'] = True
261 for zone in context['zones']:
262 zone.stats = ZoneStats(zone, **kwargs)
266 class UploadTracksView(FormView):
267 form_class = UploadTracksForm
268 template_name = 'nonstop/upload.html'
271 def post(self, request, *args, **kwargs):
272 assert self.request.user.has_perm('nonstop.add_track')
273 form_class = self.get_form_class()
274 form = self.get_form(form_class)
275 tracks = request.FILES.getlist('tracks')
276 if not form.is_valid():
277 return self.form_invalid(form)
278 missing_metadata = []
281 with tempfile.NamedTemporaryFile(prefix='track-upload') as tmpfile:
282 tmpfile.write(f.read())
284 metadata = mutagen.File(tmpfile.name, easy=True)
285 if not metadata or not metadata.get('artist') or not metadata.get('title'):
286 missing_metadata.append(f.name)
288 metadatas[f.name] = metadata
290 form.add_error('tracks', _('Missing metadata in: ') + ', '.join(missing_metadata))
291 return self.form_invalid(form)
294 metadata = metadatas[f.name]
295 artist_name = metadata.get('artist')[0]
296 track_title = metadata.get('title')[0]
298 monthdir = datetime.datetime.today().strftime('%Y-%m')
299 filepath = '%s/%s - %s - %s%s' % (monthdir,
300 datetime.datetime.today().strftime('%y%m%d'),
301 artist_name[:50].replace('/', ' ').strip(),
302 track_title[:80].replace('/', ' ').strip(),
303 os.path.splitext(f.name)[-1])
305 artist, created = Artist.objects.get_or_create(name=artist_name)
306 track, created = Track.objects.get_or_create(title=track_title, artist=artist,
307 defaults={'uploader': self.request.user})
308 if created or not track.file_exists():
309 default_storage.save(os.path.join('nonstop', 'tracks', filepath), content=f)
310 nonstop_file = NonstopFile()
311 nonstop_file.set_track_filepath(filepath)
312 nonstop_file.track = track
315 # don't keep duplicated file and do not create a duplicated nonstop file object
317 if request.POST.get('nonstop_zone'):
318 track.nonstop_zones.add(
319 Nonstop.objects.get(id=request.POST.get('nonstop_zone')))
320 track.sync_nonstop_zones()
322 messages.info(self.request, '%d new track(s)' % len(tracks))
323 return self.form_valid(form)
326 class TracksMetadataView(ListView):
327 template_name = 'nonstop/tracks_metadata.html'
329 def get_context_data(self, **kwargs):
330 context = super().get_context_data(**kwargs)
331 context['view'] = self
334 def get_queryset(self):
335 return Track.objects.exclude(nonstop_zones__isnull=True)
337 def post(self, request, *args, **kwargs):
338 assert self.request.user.has_perm('nonstop.add_track')
339 for track_id in request.POST.getlist('track'):
340 track = Track.objects.get(id=track_id)
341 track.language = request.POST.get('lang-%s' % track_id, '')
342 track.instru = 'instru-%s' % track_id in request.POST
343 track.sabam = 'sabam-%s' % track_id in request.POST
344 track.cfwb = 'cfwb-%s' % track_id in request.POST
346 return HttpResponseRedirect('.')
349 class RandomTracksMetadataView(TracksMetadataView):
350 page_title = _('Metadata of random tracks')
352 def get_queryset(self):
353 return super().get_queryset().filter(Q(language='') | Q(language__isnull=True)).order_by('?')[:50]
356 class RecentTracksMetadataView(TracksMetadataView):
357 page_title = _('Metadata of recent tracks')
359 def get_queryset(self):
360 return super().get_queryset().exclude(creation_timestamp__isnull=True).order_by('-creation_timestamp')[:50]
363 class ArtistTracksMetadataView(TracksMetadataView):
366 def page_title(self):
367 return _('Metadata of tracks from %s') % Artist.objects.get(id=self.kwargs['artist_pk']).name
369 def get_queryset(self):
370 return Track.objects.filter(artist_id=self.kwargs['artist_pk']).order_by('title')
373 class QuickLinksView(TemplateView):
374 template_name = 'nonstop/quick_links.html'
376 def get_context_data(self, **kwargs):
377 context = super().get_context_data(**kwargs)
378 day = datetime.datetime.today()
379 context['days'] = [day + datetime.timedelta(days=i) for i in range(5)]
383 class SearchView(TemplateView):
384 template_name = 'nonstop/search.html'
386 def get_queryset(self):
387 queryset = Track.objects.all()
389 q = self.request.GET.get('q')
391 queryset = queryset.filter(Q(title__icontains=q.lower()) | Q(artist__name__icontains=q.lower()))
393 zone = self.request.GET.get('zone')
395 from emissions.models import Nonstop
397 queryset = queryset.filter(nonstop_zones=None)
399 queryset = queryset.filter(nonstop_zones__isnull=False).distinct()
401 queryset = queryset.filter(nonstop_zones=zone)
403 order = self.request.GET.get('order_by') or 'title'
405 if 'added_to_nonstop_timestamp' in order:
406 queryset = queryset.filter(added_to_nonstop_timestamp__isnull=False)
407 queryset = queryset.order_by(order)
410 def get_context_data(self, **kwargs):
411 ctx = super(SearchView, self).get_context_data(**kwargs)
412 ctx['form'] = TrackSearchForm(self.request.GET)
413 queryset = self.get_queryset()
414 qs = self.request.GET.copy()
416 ctx['qs'] = qs.urlencode()
418 tracks = Paginator(queryset.select_related(), 20)
420 page = self.request.GET.get('page')
422 ctx['tracks'] = tracks.page(page)
423 except PageNotAnInteger:
424 ctx['tracks'] = tracks.page(1)
426 ctx['tracks'] = tracks.page(tracks.num_pages)
431 class SearchCsvView(SearchView):
432 def get(self, request, *args, **kwargs):
434 writer = csv.writer(out)
435 writer.writerow(['Title', 'Artist', 'Zones', 'Language', 'Instru', 'CFWB', 'Ajout'])
436 for track in self.get_queryset():
438 track.title if track.title else 'Inconnu',
439 track.artist.name if (track.artist and track.artist.name) else 'Inconnu',
440 ' + '.join([x.title for x in track.nonstop_zones.all()]),
441 track.language or '',
442 track.instru and 'instru' or '',
443 track.cfwb and 'cfwb' or '',
444 track.added_to_nonstop_timestamp.strftime('%Y-%m-%d %H:%M') if track.added_to_nonstop_timestamp else '',
446 return HttpResponse(out.getvalue(), content_type='text/csv; charset=utf-8')
449 class CleanupView(TemplateView):
450 template_name = 'nonstop/cleanup.html'
452 def get_context_data(self, **kwargs):
453 ctx = super(CleanupView, self).get_context_data(**kwargs)
454 ctx['form'] = CleanupForm()
456 zone = self.request.GET.get('zone')
458 from emissions.models import Nonstop
459 ctx['zone'] = Nonstop.objects.get(id=zone)
460 ctx['count'] = Track.objects.filter(nonstop_zones=zone).count()
461 ctx['tracks'] = Track.objects.filter(nonstop_zones=zone).order_by(
462 'added_to_nonstop_timestamp').select_related()[:30]
465 def post(self, request, *args, **kwargs):
466 assert self.request.user.has_perm('nonstop.add_track')
468 for track_id in request.POST.getlist('track'):
469 if request.POST.get('remove-%s' % track_id):
470 track = Track.objects.get(id=track_id)
471 track.nonstop_zones.clear()
472 track.sync_nonstop_zones()
475 messages.info(self.request, 'Removed %d new track(s)' % count)
476 return HttpResponseRedirect('.')
479 class AddSomaDiffusionView(CreateView):
480 model = ScheduledDiffusion
481 fields = ['jingle', 'stream']
482 template_name = 'nonstop/streamed-diffusion.html'
486 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
488 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
489 fields.append('stream')
492 def get_initial(self):
493 initial = super(AddSomaDiffusionView, self).get_initial()
494 initial['jingle'] = None
495 if 'stream' in self.fields:
496 initial['jingle'] = Jingle.objects.filter(default_for_streams=True).first()
497 initial['stream'] = Stream.objects.all().first()
500 def form_valid(self, form):
501 form.instance.diffusion_id = self.kwargs['pk']
502 episode = form.instance.diffusion.episode
503 if 'stream' in self.fields and form.instance.stream_id is None:
504 messages.error(self.request, _('missing stream'))
505 return HttpResponseRedirect(reverse('episode-view', kwargs={
506 'emission_slug': episode.emission.slug,
507 'slug': episode.slug}))
508 response = super(AddSomaDiffusionView, self).form_valid(form)
509 messages.info(self.request, _('%s added to schedule') % episode.emission.title)
512 def get_success_url(self):
513 diffusion = Diffusion.objects.get(id=self.kwargs['pk'])
514 episode = diffusion.episode
515 return reverse('episode-view', kwargs={
516 'emission_slug': episode.emission.slug,
517 'slug': episode.slug})
520 class DelSomaDiffusionView(RedirectView):
521 def get_redirect_url(self, pk):
522 soma_diffusion = ScheduledDiffusion.objects.filter(diffusion_id=pk).first()
523 episode = soma_diffusion.episode
524 ScheduledDiffusion.objects.filter(diffusion_id=pk).update(diffusion_id=None)
525 messages.info(self.request, _('%s removed from schedule') % episode.emission.title)
526 return reverse('episode-view', kwargs={
527 'emission_slug': episode.emission.slug,
528 'slug': episode.slug})
531 class DiffusionPropertiesView(UpdateView):
532 model = ScheduledDiffusion
533 fields = ['jingle', 'stream']
534 template_name = 'nonstop/streamed-diffusion.html'
538 diffusion = self.get_object().diffusion
540 if not diffusion.episode.soundfile_set.filter(fragment=False).exists():
541 fields.append('stream')
544 def form_valid(self, form):
545 episode = self.get_object().diffusion.episode
546 if 'stream' in self.fields and form.instance.stream_id is None:
547 messages.error(self.request, _('missing stream'))
548 return HttpResponseRedirect(reverse('episode-view', kwargs={
549 'emission_slug': episode.emission.slug,
550 'slug': episode.slug}))
551 response = super(DiffusionPropertiesView, self).form_valid(form)
552 messages.info(self.request, _('%s diffusion properties have been updated.') % episode.emission.title)
555 def get_success_url(self):
556 episode = self.get_object().diffusion.episode
557 return reverse('episode-view', kwargs={
558 'emission_slug': episode.emission.slug,
559 'slug': episode.slug})
562 def jingle_audio_view(request, *args, **kwargs):
563 jingle = Jingle.objects.get(id=kwargs['pk'])
564 return FileResponse(open(os.path.join(app_settings.LOCAL_BASE_PATH, app_settings.JINGLES_PREFIX, jingle.filepath), 'rb'))
567 class AjaxProgram(TemplateView):
568 template_name = 'nonstop/program-fragment.html'
570 def get_context_data(self, date, **kwargs):
571 context = super().get_context_data(**kwargs)
572 now = datetime.datetime.now()
574 date_start = datetime.datetime.strptime(date, '%Y-%m-%d')
576 date_start = datetime.datetime.today()
577 date_start = date_start.replace(hour=5, minute=0, second=0, microsecond=0)
578 today = bool(date_start.timetuple()[:3] == now.timetuple()[:3])
579 date_end = date_start + datetime.timedelta(days=1)
580 context['day_program'] = period_program(date_start, date_end, prefetch_categories=False)
581 for x in context['day_program']:
582 x.klass = x.__class__.__name__
584 for i, x in enumerate(context['day_program']):
585 if today and x.datetime > now and previous_prog:
586 previous_prog.now = True
592 class ZoneSettings(FormView):
593 form_class = ZoneSettingsForm
594 template_name = 'nonstop/zone_settings.html'
595 success_url = reverse_lazy('nonstop-zones')
597 def get_context_data(self, **kwargs):
598 context = super().get_context_data(**kwargs)
599 context['zone'] = Nonstop.objects.get(slug=self.kwargs['slug'])
602 def get_initial(self):
604 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
605 except Nonstop.DoesNotExist:
607 zone_settings = zone.nonstopzonesettings_set.first()
608 if zone_settings is None:
609 zone_settings = NonstopZoneSettings(nonstop=zone)
611 initial = super().get_initial()
612 initial['start'] = zone.start.strftime('%H:%M') if zone.start else None
613 initial['end'] = zone.end.strftime('%H:%M') if zone.end else None
614 initial['intro_jingle'] = zone_settings.intro_jingle_id
615 initial['jingles'] = [x.id for x in zone_settings.jingles.all()]
616 for key, value in zone_settings.weights.items():
617 initial['weight_%s' % key] = value
620 def form_valid(self, form):
621 if not self.request.user.has_perm('nonstop.change_nonstopzonesettings'):
622 raise PermissionDenied()
623 zone = Nonstop.objects.get(slug=self.kwargs['slug'])
624 zone_settings = zone.nonstopzonesettings_set.first()
625 zone.start = form.cleaned_data['start']
626 zone.end = form.cleaned_data['end']
627 zone_settings.jingles.set(form.cleaned_data['jingles'])
628 zone_settings.intro_jingle_id = form.cleaned_data['intro_jingle']
629 weights = {key[7:]: value for key, value in form.cleaned_data.items() if key.startswith('weight_')}
630 zone_settings.weights = weights
633 return super().form_valid(form)
636 class MuninTracks(StatisticsView):
637 template_name = 'nonstop/munin_tracks.txt'
638 content_type = 'text/plain; charset=utf-8'
640 def get_context_data(self, **kwargs):
641 context = super().get_context_data(**kwargs)
642 context['nonstop_general_total'] = Track.objects.count()
643 active_tracks_qs = Track.objects.filter(nonstop_zones__isnull=False).distinct()
644 context['nonstop_general_active'] = active_tracks_qs.count()
645 context['nonstop_percentages_instru'] = 100 * (
646 active_tracks_qs.filter(instru=True).count() /
647 context['nonstop_general_active'])
648 context['nonstop_percentages_cfwb'] = 100 * (
649 active_tracks_qs.filter(cfwb=True).count() /
650 context['nonstop_general_active'])
651 context['nonstop_percentages_langset'] = 100 * (
652 active_tracks_qs.exclude(language='').count() /
653 context['nonstop_general_active'])
654 context['nonstop_percentages_french'] = 100 * (
655 active_tracks_qs.filter(language='fr').count() /
656 active_tracks_qs.exclude(language__isnull=True).exclude(language__in=('', 'na')).count())
660 class ZoneTracklistPercents(DetailView):
663 def get(self, request, *args, **kwargs):
664 zone = self.get_object()
665 zone_settings = zone.nonstopzonesettings_set.first()
666 weights = {key[7:]: int(value) for key, value in request.GET.items() if key.startswith('weight_')}
667 zone_settings.weights = weights
669 tracklist = utils.Tracklist(zone_settings, zone_ids=[zone.id])
670 random_tracks_iterator = tracklist.get_random_tracks(k=100)
672 counts = collections.defaultdict(int)
674 for i, track in enumerate(random_tracks_iterator):
677 for weight in weights:
678 if track.match_criteria(weight):
682 for weight in weights:
683 data[weight] = counts[weight] / 1000
685 return JsonResponse(data)