Marietje 4.1: Addition of Django REST framework, Swagger, Dark mode and updates to Django and Bootstrap

This commit is contained in:
Lars van Rhijn
2023-09-14 19:55:51 +02:00
parent 379ababcc0
commit d1a1be7e2e
124 changed files with 4835 additions and 3490 deletions

View File

@ -9,35 +9,36 @@ from .models import ReportNote, Song
class ReportNoteInline(admin.StackedInline):
model = ReportNote
extra = 0
autocomplete_fields = ('user',)
autocomplete_fields = ("user",)
class SongHasReportNoteFilter(admin.SimpleListFilter):
title = 'report notes'
parameter_name = 'reportnotes'
title = "report notes"
parameter_name = "reportnotes"
def lookups(self, request, model_admin):
return (
('yes', 'yes'),
('no', 'no'),
("yes", "yes"),
("no", "no"),
)
def queryset(self, request, queryset):
queryset = queryset.annotate(num_reports=Count('reportnote'))
if self.value() == 'yes':
queryset = queryset.annotate(num_reports=Count("reportnote"))
if self.value() == "yes":
return queryset.exclude(num_reports=0)
if self.value() == 'no':
if self.value() == "no":
return queryset.filter(num_reports=0)
return queryset
@admin.register(Song)
class SongAdmin(admin.ModelAdmin):
list_display = ('artist', 'title', 'user_name', 'reports')
list_display_links = ('artist', 'title')
list_display = ("artist", "title", "user_name", "reports")
list_display_links = ("artist", "title")
list_filter = (SongHasReportNoteFilter,)
search_fields = ('artist', 'title', 'user__name')
search_fields = ("artist", "title", "user__name")
inlines = [ReportNoteInline]
autocomplete_fields = ('user',)
autocomplete_fields = ("user",)
@staticmethod
def reports(song):
@ -49,19 +50,20 @@ class SongAdmin(admin.ModelAdmin):
try:
return song.user.name
except AttributeError:
return '<unknown>'
return "<unknown>"
@staticmethod
def get_readonly_fields(request, obj=None):
return [] if request.user.is_superuser else ['hash']
return [] if request.user.is_superuser else ["hash"]
@admin.register(ReportNote)
class ReportNoteAdmin(admin.ModelAdmin):
exclude = ('song',)
list_display = ('song', 'note', 'user')
search_fields = ('song__artist', 'song__title', 'user__name')
autocomplete_fields = ('user',)
readonly_fields = ('song_link',)
exclude = ("song",)
list_display = ("song", "note", "user")
search_fields = ("song__artist", "song__title", "user__name")
autocomplete_fields = ("user",)
readonly_fields = ("song_link",)
@staticmethod
def song_link(note):

View File

@ -0,0 +1,24 @@
from rest_framework import serializers
from marietje.api.v1.serializers import UserRelatedFieldSerializer
from songs.models import Song, ReportNote
class SongSerializer(serializers.ModelSerializer):
user = UserRelatedFieldSerializer()
class Meta:
model = Song
fields = ["id", "artist", "title", "duration", "hash", "user", "rg_gain", "rg_peak"]
class ReportNoteSerializer(serializers.ModelSerializer):
user = UserRelatedFieldSerializer(many=False, read_only=True)
class Meta:
model = ReportNote
fields = [
"song",
"note",
"user",
]

View File

@ -0,0 +1,10 @@
from django.urls import path
from .views import SongsListAPIView, SongRetrieveAPIView, SongUploadAPIView, ReportNoteCreateAPIView
urlpatterns = [
path("", SongsListAPIView.as_view(), name="song_list"),
path("<int:pk>/", SongRetrieveAPIView.as_view(), name="song_retrieve"),
path("report-notes/", ReportNoteCreateAPIView.as_view(), name="report_note_create"),
path("upload/", SongUploadAPIView.as_view(), name="song_upload"),
]

View File

@ -0,0 +1,96 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView, CreateAPIView
from rest_framework import filters
from rest_framework.views import APIView
from rest_framework.response import Response
from marietje.api.permissions import IsAuthenticatedOrTokenHasScopeForMethod
from songs.api.v1.serializers import SongSerializer, ReportNoteSerializer
from songs.counters import upload_counter
from songs.models import Song
from songs.services import check_upload_stats, get_reputation, upload_file, UploadException
class SongsListAPIView(ListAPIView):
serializer_class = SongSerializer
queryset = Song.objects.all()
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {"GET": ["read"]}
filter_backends = (filters.SearchFilter, filters.OrderingFilter)
search_fields = ["artist", "title", "user__name", "user__username"]
ordering_fields = [
"artist",
"title",
"duration",
]
class SongRetrieveAPIView(RetrieveAPIView):
serializer_class = SongSerializer
queryset = Song.objects.all()
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {"GET": ["read"]}
class ReportNoteCreateAPIView(CreateAPIView):
serializer_class = ReportNoteSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {"POST": ["write"]}
def perform_create(self, serializer):
serializer.save(user=self.request.user)
def create(self, request, *args, **kwargs):
if self.request.user is None:
return Response(
status=403, data={"success": False, "errorMessage": "A user is necessary for creating a report note."}
)
else:
return super(ReportNoteCreateAPIView, self).create(request, *args, **kwargs)
class SongUploadAPIView(APIView):
serializer_class = SongSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"POST": ["write"],
}
def post(self, request, **kwargs):
if request.user is None:
return Response(
status=403, data={"success": False, "errorMessage": "A user is necessary for uploading a song."}
)
file = request.data.get("file", None)
artist = request.data.get("artist", None)
title = request.data.get("title", None)
if file is None:
return Response(status=400, data={"success": False, "errorMessage": "Please select a file to upload."})
if artist is None or artist == "":
return Response(status=400, data={"success": False, "errorMessage": "Please set an artist for this file."})
if title is None or title == "":
return Response(status=400, data={"success": False, "errorMessage": "Please set a title for this file."})
if not check_upload_stats(request.user):
reputation = get_reputation(request.user)
msg = (
"Queue-to-upload ratio too low. Please queue more during regular opening hours to improve the "
"ratio. (Ratio: {} ≱ 1.00)"
)
return Response(status=403, data={"success": False, "errorMessage": msg.format(reputation)})
try:
song = upload_file(file, artist, title, request.user)
upload_counter.inc()
return Response(status=200, data=self.serializer_class(song).data)
except UploadException:
return Response(
status=500,
data={
"success": False,
"errorMessage": "File could not be uploaded due to an exception that "
"occurred while contacting the file server, please try "
"again.",
},
)

View File

@ -2,4 +2,5 @@ from django.apps import AppConfig
class SongsConfig(AppConfig):
name = 'songs'
name = "songs"
default_auto_field = "django.db.models.BigAutoField"

View File

@ -0,0 +1,4 @@
from prometheus_client import Counter
request_counter = Counter("marietje_requests", "Queue requests on marietje", ["queue"])
upload_counter = Counter("marietje_uploads", "Songs uploaded to marietje")

View File

@ -3,12 +3,14 @@ from django.core.management.base import BaseCommand
from songs.models import ReportNote
class Command(BaseCommand):
help = 'Gather all song reports'
help = "Gather all song reports"
def handle(self, *args, **options):
reports = ReportNote.objects.all()
for report in reports:
song = report.song
url = '<{base_url}/admin/songs/song/{r.song.id}/change/>'.format(base_url=settings.BASE_URL, r=report)
print('Song: {r.song.artist} - {r.song.title}\nMessage: {r.note}\nLink: {url}'.format(url=url, r=report))
print('-' * 72)
url = "<{base_url}/admin/songs/song/{r.song.id}/change/>".format(base_url=settings.BASE_URL, r=report)
print("Song: {r.song.artist} - {r.song.title}\nMessage: {r.note}\nLink: {url}".format(url=url, r=report))
print("-" * 72)

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-01-04 09:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('songs', '0004_reportnote'),
]
operations = [
migrations.AlterField(
model_name='reportnote',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='song',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -3,62 +3,34 @@ from django.conf import settings
class Song(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True,
db_index=True)
artist = models.CharField(
max_length=200, db_index=True, help_text='track artist')
title = models.CharField(
max_length=200, db_index=True, help_text='track title')
hash = models.CharField(
max_length=64, help_text="track file's SHA256 hash")
duration = models.IntegerField(help_text='track duration in seconds')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True, db_index=True)
artist = models.CharField(max_length=200, db_index=True, help_text="track artist")
title = models.CharField(max_length=200, db_index=True, help_text="track title")
hash = models.CharField(max_length=64, help_text="track file's SHA256 hash")
duration = models.IntegerField(help_text="track duration in seconds")
rg_gain = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True,
help_text='replaygain gain level')
max_digits=9, decimal_places=6, blank=True, null=True, help_text="replaygain gain level"
)
rg_peak = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True,
help_text='replaygain peak level')
max_digits=9, decimal_places=6, blank=True, null=True, help_text="replaygain peak level"
)
old_id = models.TextField(blank=True, null=True, default=None)
deleted = models.BooleanField(
verbose_name='unlisted',
default=False,
db_index=True,
help_text='hide this song from the search listings')
verbose_name="unlisted", default=False, db_index=True, help_text="hide this song from the search listings"
)
def report(self, user, note):
report_note = ReportNote(song=self, user=user, note=note)
report_note.save()
def __str__(self):
return self.artist + ' - ' + self.title
return self.artist + " - " + self.title
class ReportNote(models.Model):
song = models.ForeignKey(
Song,
on_delete=models.CASCADE,
blank=False,
null=False,
db_index=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
db_index=True)
note = models.TextField(
verbose_name='reason',
blank=True,
help_text='reason for edit request')
song = models.ForeignKey(Song, on_delete=models.CASCADE, blank=False, null=False, db_index=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, db_index=True)
note = models.TextField(verbose_name="reason", blank=True, help_text="reason for edit request")
def __str__(self):
return "{song.artist} - {song.title}: '{note}'".format(song=self.song, note=self.note)

View File

@ -0,0 +1,68 @@
from marietje.utils import send_to_bertha
from queues.models import PlaylistSong
from songs.models import Song
from django.db.models.functions import Coalesce
from django.db.models import Sum, Value
from django.db import transaction
from mutagen import File
class UploadException(Exception):
pass
def is_regular_queue(ps):
if not ps.played_at:
# Request is from the old times, assume good
return True
if not 0 <= ps.played_at.astimezone().weekday() <= 4:
return False # Queued in the weekend
if not 7 <= ps.played_at.astimezone().hour <= 22:
# Because of timezone shit, I allow for an extra hour of leeway
return False # Queued outside of regular opening hours
return True
@transaction.atomic
def request_weight(ps):
if is_regular_queue(ps):
return float(ps.song.duration)
# Count other requests for 10%
return 0.10 * float(ps.song.duration)
def get_upload_stats(user):
songs_queued = PlaylistSong.objects.select_related("song").filter(user=user, state=2, song__deleted=False)
queued_score = sum(request_weight(x) for x in songs_queued)
upload_score = Song.objects.filter(user=user, deleted=False).aggregate(x=Coalesce(Sum("duration"), Value(0)))["x"]
return {"queued_score": queued_score, "upload_score": upload_score}
def get_reputation(user) -> float:
try:
stats = get_upload_stats(user)
ratio = stats["queued_score"] / (2.0 * stats["upload_score"])
return ratio
except ZeroDivisionError:
return 99999.0 # high enough
def check_upload_stats(user):
# Allow upload if the user has a good reputation
# Score function:
# - U = duration * songs uploaded
# - Q = duration * songs queued
# - If 2*U < Q: allow upload (otherwise don't)
if user.is_superuser:
return True
ratio = get_reputation(user)
return ratio >= 1.0
def upload_file(file, artist, title, user):
duration = File(file).info.length
bertha_hash = send_to_bertha(file).decode("ascii")
if not bertha_hash:
raise UploadException("Files not uploaded correctly.")
return Song.objects.create(user=user, artist=artist, title=title, hash=bertha_hash, duration=duration)

View File

@ -0,0 +1,27 @@
.clearfix{*zoom:1;}.clearfix:before,.clearfix:after{display:table;content:"";line-height:0;}
.clearfix:after{clear:both;}
.hide-text{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0;}
.input-block-level{display:block;width:100%;min-height:30px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;}
.btn-file{overflow:hidden;position:relative;vertical-align:middle;}.btn-file>input{position:absolute;top:0;right:0;margin:0;opacity:0;filter:alpha(opacity=0);transform:translate(-300px, 0) scale(4);font-size:23px;direction:ltr;cursor:pointer;}
.fileupload{margin-bottom:9px;}.fileupload .uneditable-input{display:inline-block;margin-bottom:0px;vertical-align:middle;cursor:text;}
.fileupload .thumbnail{overflow:hidden;display:inline-block;margin-bottom:5px;vertical-align:middle;text-align:center;}.fileupload .thumbnail>img{display:inline-block;vertical-align:middle;max-height:100%;}
.fileupload .btn{vertical-align:middle;}
.fileupload-exists .fileupload-new,.fileupload-new .fileupload-exists{display:none;}
.fileupload-inline .fileupload-controls{display:inline;}
.fileupload-new .input-append .btn-file{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
.thumbnail-borderless .thumbnail{border:none;padding:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
.fileupload-new.thumbnail-borderless .thumbnail{border:1px solid #ddd;}
.control-group.warning .fileupload .uneditable-input{color:#a47e3c;border-color:#a47e3c;}
.control-group.warning .fileupload .fileupload-preview{color:#a47e3c;}
.control-group.warning .fileupload .thumbnail{border-color:#a47e3c;}
.control-group.error .fileupload .uneditable-input{color:#b94a48;border-color:#b94a48;}
.control-group.error .fileupload .fileupload-preview{color:#b94a48;}
.control-group.error .fileupload .thumbnail{border-color:#b94a48;}
.control-group.success .fileupload .uneditable-input{color:#468847;border-color:#468847;}
.control-group.success .fileupload .fileupload-preview{color:#468847;}
.control-group.success .fileupload .thumbnail{border-color:#468847;}
#add, #remove {
width: 34px;
height: 34px;
}

View File

@ -0,0 +1,25 @@
function y(h,g,b){var c=g||0,d=0;"string"==typeof h?(d=b||h.length,this.a=function(a){return h.charCodeAt(a+c)&255}):"unknown"==typeof h&&(d=b||IEBinary_getLength(h),this.a=function(a){return IEBinary_getByteAt(h,a+c)});this.l=function(a,f){for(var v=Array(f),b=0;b<f;b++)v[b]=this.a(a+b);return v};this.h=function(){return d};this.d=function(a,f){return 0!=(this.a(a)&1<<f)};this.w=function(a){a=(this.a(a+1)<<8)+this.a(a);0>a&&(a+=65536);return a};this.i=function(a){var f=this.a(a),b=this.a(a+1),d=
this.a(a+2);a=this.a(a+3);f=(((f<<8)+b<<8)+d<<8)+a;0>f&&(f+=4294967296);return f};this.o=function(a){var f=this.a(a),b=this.a(a+1);a=this.a(a+2);f=((f<<8)+b<<8)+a;0>f&&(f+=16777216);return f};this.c=function(a,f){for(var b=[],d=a,e=0;d<a+f;d++,e++)b[e]=String.fromCharCode(this.a(d));return b.join("")};this.e=function(a,b,d){a=this.l(a,b);switch(d.toLowerCase()){case "utf-16":case "utf-16le":case "utf-16be":b=d;var l,e=0,c=1;d=0;l=Math.min(l||a.length,a.length);254==a[0]&&255==a[1]?(b=!0,e=2):255==
a[0]&&254==a[1]&&(b=!1,e=2);b&&(c=0,d=1);b=[];for(var m=0;e<l;m++){var g=a[e+c],k=(g<<8)+a[e+d],e=e+2;if(0==k)break;else 216>g||224<=g?b[m]=String.fromCharCode(k):(g=(a[e+c]<<8)+a[e+d],e+=2,b[m]=String.fromCharCode(k,g))}a=new String(b.join(""));a.g=e;break;case "utf-8":l=0;e=Math.min(e||a.length,a.length);239==a[0]&&187==a[1]&&191==a[2]&&(l=3);c=[];for(d=0;l<e&&(b=a[l++],0!=b);d++)128>b?c[d]=String.fromCharCode(b):194<=b&&224>b?(m=a[l++],c[d]=String.fromCharCode(((b&31)<<6)+(m&63))):224<=b&&240>
b?(m=a[l++],k=a[l++],c[d]=String.fromCharCode(((b&255)<<12)+((m&63)<<6)+(k&63))):240<=b&&245>b&&(m=a[l++],k=a[l++],g=a[l++],b=((b&7)<<18)+((m&63)<<12)+((k&63)<<6)+(g&63)-65536,c[d]=String.fromCharCode((b>>10)+55296,(b&1023)+56320));a=new String(c.join(""));a.g=l;break;default:e=[];c=c||a.length;for(l=0;l<c;){d=a[l++];if(0==d)break;e[l-1]=String.fromCharCode(d)}a=new String(e.join(""));a.g=l}return a};this.f=function(a,b){b()}}var B=document.createElement("script");B.type="text/vbscript";
B.textContent="Function IEBinary_getByteAt(strBinary, iOffset)\r\n\tIEBinary_getByteAt = AscB(MidB(strBinary,iOffset+1,1))\r\nEnd Function\r\nFunction IEBinary_getLength(strBinary)\r\n\tIEBinary_getLength = LenB(strBinary)\r\nEnd Function\r\n";document.getElementsByTagName("head")[0].appendChild(B);function C(h,g,b){function c(a,b,e,c,f,g){var k=d();k?("undefined"===typeof g&&(g=!0),b&&("undefined"!=typeof k.onload?(k.onload=function(){"200"==k.status||"206"==k.status?(k.fileSize=f||k.getResponseHeader("Content-Length"),b(k)):e&&e({error:"xhr",xhr:k});k=null},e&&(k.onerror=function(){e({error:"xhr",xhr:k});k=null})):k.onreadystatechange=function(){4==k.readyState&&("200"==k.status||"206"==k.status?(k.fileSize=f||k.getResponseHeader("Content-Length"),b(k)):e&&e({error:"xhr",xhr:k}),k=null)}),
k.open("GET",a,g),k.overrideMimeType&&k.overrideMimeType("text/plain; charset=x-user-defined"),c&&k.setRequestHeader("Range","bytes="+c[0]+"-"+c[1]),k.setRequestHeader("If-Modified-Since","Sat, 1 Jan 1970 00:00:00 GMT"),k.send(null)):e&&e({error:"Unable to create XHR object"})}function d(){var a=null;window.XMLHttpRequest?a=new XMLHttpRequest:window.ActiveXObject&&(a=new ActiveXObject("Microsoft.XMLHTTP"));return a}function a(a,b,e){var c=d();c?(b&&("undefined"!=typeof c.onload?(c.onload=function(){"200"==
c.status||"206"==c.status?b(this):e&&e({error:"xhr",xhr:c});c=null},e&&(c.onerror=function(){e({error:"xhr",xhr:c});c=null})):c.onreadystatechange=function(){4==c.readyState&&("200"==c.status||"206"==c.status?b(this):e&&e({error:"xhr",xhr:c}),c=null)}),c.open("HEAD",a,!0),c.send(null)):e&&e({error:"Unable to create XHR object"})}function f(a,d){var e,f;function g(a){var b=~~(a[0]/e)-f;a=~~(a[1]/e)+1+f;0>b&&(b=0);a>=blockTotal&&(a=blockTotal-1);return[b,a]}function h(f,g){for(;n[f[0]];)if(f[0]++,f[0]>
f[1]){g&&g();return}for(;n[f[1]];)if(f[1]--,f[0]>f[1]){g&&g();return}var m=[f[0]*e,(f[1]+1)*e-1];c(a,function(a){parseInt(a.getResponseHeader("Content-Length"),10)==d&&(f[0]=0,f[1]=blockTotal-1,m[0]=0,m[1]=d-1);a={data:a.N||a.responseText,offset:m[0]};for(var b=f[0];b<=f[1];b++)n[b]=a;g&&g()},b,m,k,!!g)}var k,r=new y("",0,d),n=[];e=e||2048;f="undefined"===typeof f?0:f;blockTotal=~~((d-1)/e)+1;for(var q in r)r.hasOwnProperty(q)&&"function"===typeof r[q]&&(this[q]=r[q]);this.a=function(a){var b;h(g([a,
a]));b=n[~~(a/e)];if("string"==typeof b.data)return b.data.charCodeAt(a-b.offset)&255;if("unknown"==typeof b.data)return IEBinary_getByteAt(b.data,a-b.offset)};this.f=function(a,b){h(g(a),b)}}(function(){a(h,function(a){a=parseInt(a.getResponseHeader("Content-Length"),10)||-1;g(new f(h,a))},b)})()};(function(h){h.FileAPIReader=function(g,b){return function(c,d){var a=b||new FileReader;a.onload=function(a){d(new y(a.target.result))};a.readAsBinaryString(g)}}})(this);(function(h){var g=h.p={},b={},c=[0,7];g.t=function(d){delete b[d]};g.s=function(){b={}};g.B=function(d,a,f){f=f||{};(f.dataReader||C)(d,function(g){g.f(c,function(){var c="ftypM4A"==g.c(4,7)?ID4:"ID3"==g.c(0,3)?ID3v2:ID3v1;c.m(g,function(){var e=f.tags,h=c.n(g,e),e=b[d]||{},m;for(m in h)h.hasOwnProperty(m)&&(e[m]=h[m]);b[d]=e;a&&a()})})},f.onError)};g.v=function(d){if(!b[d])return null;var a={},c;for(c in b[d])b[d].hasOwnProperty(c)&&(a[c]=b[d][c]);return a};g.A=function(d,a){return b[d]?b[d][a]:
null};h.ID3=h.p;g.loadTags=g.B;g.getAllTags=g.v;g.getTag=g.A;g.clearTags=g.t;g.clearAll=g.s})(this);(function(h){var g=h.q={},b="Blues;Classic Rock;Country;Dance;Disco;Funk;Grunge;Hip-Hop;Jazz;Metal;New Age;Oldies;Other;Pop;R&B;Rap;Reggae;Rock;Techno;Industrial;Alternative;Ska;Death Metal;Pranks;Soundtrack;Euro-Techno;Ambient;Trip-Hop;Vocal;Jazz+Funk;Fusion;Trance;Classical;Instrumental;Acid;House;Game;Sound Clip;Gospel;Noise;AlternRock;Bass;Soul;Punk;Space;Meditative;Instrumental Pop;Instrumental Rock;Ethnic;Gothic;Darkwave;Techno-Industrial;Electronic;Pop-Folk;Eurodance;Dream;Southern Rock;Comedy;Cult;Gangsta;Top 40;Christian Rap;Pop/Funk;Jungle;Native American;Cabaret;New Wave;Psychadelic;Rave;Showtunes;Trailer;Lo-Fi;Tribal;Acid Punk;Acid Jazz;Polka;Retro;Musical;Rock & Roll;Hard Rock;Folk;Folk-Rock;National Folk;Swing;Fast Fusion;Bebob;Latin;Revival;Celtic;Bluegrass;Avantgarde;Gothic Rock;Progressive Rock;Psychedelic Rock;Symphonic Rock;Slow Rock;Big Band;Chorus;Easy Listening;Acoustic;Humour;Speech;Chanson;Opera;Chamber Music;Sonata;Symphony;Booty Bass;Primus;Porn Groove;Satire;Slow Jam;Club;Tango;Samba;Folklore;Ballad;Power Ballad;Rhythmic Soul;Freestyle;Duet;Punk Rock;Drum Solo;Acapella;Euro-House;Dance Hall".split(";");
g.m=function(b,d){var a=b.h();b.f([a-128-1,a],d)};g.n=function(c){var d=c.h()-128;if("TAG"==c.c(d,3)){var a=c.c(d+3,30).replace(/\0/g,""),f=c.c(d+33,30).replace(/\0/g,""),g=c.c(d+63,30).replace(/\0/g,""),l=c.c(d+93,4).replace(/\0/g,"");if(0==c.a(d+97+28))var e=c.c(d+97,28).replace(/\0/g,""),h=c.a(d+97+29);else e="",h=0;c=c.a(d+97+30);return{version:"1.1",title:a,artist:f,album:g,year:l,comment:e,track:h,genre:255>c?b[c]:""}}return{}};h.ID3v1=h.q})(this);(function(h){function g(a,b){var d=b.a(a),c=b.a(a+1),e=b.a(a+2);return b.a(a+3)&127|(e&127)<<7|(c&127)<<14|(d&127)<<21}var b=h.D={};b.b={};b.frames={BUF:"Recommended buffer size",CNT:"Play counter",COM:"Comments",CRA:"Audio encryption",CRM:"Encrypted meta frame",ETC:"Event timing codes",EQU:"Equalization",GEO:"General encapsulated object",IPL:"Involved people list",LNK:"Linked information",MCI:"Music CD Identifier",MLL:"MPEG location lookup table",PIC:"Attached picture",POP:"Popularimeter",REV:"Reverb",
RVA:"Relative volume adjustment",SLT:"Synchronized lyric/text",STC:"Synced tempo codes",TAL:"Album/Movie/Show title",TBP:"BPM (Beats Per Minute)",TCM:"Composer",TCO:"Content type",TCR:"Copyright message",TDA:"Date",TDY:"Playlist delay",TEN:"Encoded by",TFT:"File type",TIM:"Time",TKE:"Initial key",TLA:"Language(s)",TLE:"Length",TMT:"Media type",TOA:"Original artist(s)/performer(s)",TOF:"Original filename",TOL:"Original Lyricist(s)/text writer(s)",TOR:"Original release year",TOT:"Original album/Movie/Show title",
TP1:"Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group",TP2:"Band/Orchestra/Accompaniment",TP3:"Conductor/Performer refinement",TP4:"Interpreted, remixed, or otherwise modified by",TPA:"Part of a set",TPB:"Publisher",TRC:"ISRC (International Standard Recording Code)",TRD:"Recording dates",TRK:"Track number/Position in set",TSI:"Size",TSS:"Software/hardware and settings used for encoding",TT1:"Content group description",TT2:"Title/Songname/Content description",TT3:"Subtitle/Description refinement",
TXT:"Lyricist/text writer",TXX:"User defined text information frame",TYE:"Year",UFI:"Unique file identifier",ULT:"Unsychronized lyric/text transcription",WAF:"Official audio file webpage",WAR:"Official artist/performer webpage",WAS:"Official audio source webpage",WCM:"Commercial information",WCP:"Copyright/Legal information",WPB:"Publishers official webpage",WXX:"User defined URL link frame",AENC:"Audio encryption",APIC:"Attached picture",COMM:"Comments",COMR:"Commercial frame",ENCR:"Encryption method registration",
EQUA:"Equalization",ETCO:"Event timing codes",GEOB:"General encapsulated object",GRID:"Group identification registration",IPLS:"Involved people list",LINK:"Linked information",MCDI:"Music CD identifier",MLLT:"MPEG location lookup table",OWNE:"Ownership frame",PRIV:"Private frame",PCNT:"Play counter",POPM:"Popularimeter",POSS:"Position synchronisation frame",RBUF:"Recommended buffer size",RVAD:"Relative volume adjustment",RVRB:"Reverb",SYLT:"Synchronized lyric/text",SYTC:"Synchronized tempo codes",
TALB:"Album/Movie/Show title",TBPM:"BPM (beats per minute)",TCOM:"Composer",TCON:"Content type",TCOP:"Copyright message",TDAT:"Date",TDLY:"Playlist delay",TENC:"Encoded by",TEXT:"Lyricist/Text writer",TFLT:"File type",TIME:"Time",TIT1:"Content group description",TIT2:"Title/songname/content description",TIT3:"Subtitle/Description refinement",TKEY:"Initial key",TLAN:"Language(s)",TLEN:"Length",TMED:"Media type",TOAL:"Original album/movie/show title",TOFN:"Original filename",TOLY:"Original lyricist(s)/text writer(s)",
TOPE:"Original artist(s)/performer(s)",TORY:"Original release year",TOWN:"File owner/licensee",TPE1:"Lead performer(s)/Soloist(s)",TPE2:"Band/orchestra/accompaniment",TPE3:"Conductor/performer refinement",TPE4:"Interpreted, remixed, or otherwise modified by",TPOS:"Part of a set",TPUB:"Publisher",TRCK:"Track number/Position in set",TRDA:"Recording dates",TRSN:"Internet radio station name",TRSO:"Internet radio station owner",TSIZ:"Size",TSRC:"ISRC (international standard recording code)",TSSE:"Software/Hardware and settings used for encoding",
TYER:"Year",TXXX:"User defined text information frame",UFID:"Unique file identifier",USER:"Terms of use",USLT:"Unsychronized lyric/text transcription",WCOM:"Commercial information",WCOP:"Copyright/Legal information",WOAF:"Official audio file webpage",WOAR:"Official artist/performer webpage",WOAS:"Official audio source webpage",WORS:"Official internet radio station homepage",WPAY:"Payment",WPUB:"Publishers official webpage",WXXX:"User defined URL link frame"};var c={title:["TIT2","TT2"],artist:["TPE1",
"TP1"],album:["TALB","TAL"],year:["TYER","TYE"],comment:["COMM","COM"],track:["TRCK","TRK"],genre:["TCON","TCO"],picture:["APIC","PIC"],lyrics:["USLT","ULT"]},d=["title","artist","album","track"];b.m=function(a,b){a.f([0,g(6,a)],b)};b.n=function(a,f){var h=0,l=a.a(h+3);if(4<l)return{version:">2.4"};var e=a.a(h+4),t=a.d(h+5,7),m=a.d(h+5,6),u=a.d(h+5,5),k=g(h+6,a),h=h+10;if(m)var r=a.i(h),h=h+(r+4);var l={version:"2."+l+"."+e,major:l,revision:e,flags:{unsynchronisation:t,extended_header:m,experimental_indicator:u},
size:k},n;if(t)n={};else{for(var k=k-10,t=a,e=f,m={},u=l.major,r=[],q=0,p;p=(e||d)[q];q++)r=r.concat(c[p]||[p]);for(e=r;h<k;){r=null;q=t;p=h;var x=null;switch(u){case 2:n=q.c(p,3);var s=q.o(p+3),w=6;break;case 3:n=q.c(p,4);s=q.i(p+4);w=10;break;case 4:n=q.c(p,4),s=g(p+4,q),w=10}if(""==n)break;h+=w+s;0>e.indexOf(n)||(2<u&&(x={message:{P:q.d(p+8,6),I:q.d(p+8,5),M:q.d(p+8,4)},k:{K:q.d(p+8+1,7),F:q.d(p+8+1,3),H:q.d(p+8+1,2),C:q.d(p+8+1,1),u:q.d(p+8+1,0)}}),p+=w,x&&x.k.u&&(g(p,q),p+=4,s-=4),x&&x.k.C||
(n in b.b?r=b.b[n]:"T"==n[0]&&(r=b.b["T*"]),r=r?r(p,s,q,x):void 0,r={id:n,size:s,description:n in b.frames?b.frames[n]:"Unknown",data:r},n in m?(m[n].id&&(m[n]=[m[n]]),m[n].push(r)):m[n]=r))}n=m}for(var z in c)if(c.hasOwnProperty(z)){a:{s=c[z];"string"==typeof s&&(s=[s]);w=0;for(h=void 0;h=s[w];w++)if(h in n){a=n[h].data;break a}a=void 0}a&&(l[z]=a)}for(var A in n)n.hasOwnProperty(A)&&(l[A]=n[A]);return l};h.ID3v2=b})(this);(function(){function h(b){var c;switch(b){case 0:c="iso-8859-1";break;case 1:c="utf-16";break;case 2:c="utf-16be";break;case 3:c="utf-8"}return c}var g="32x32 pixels 'file icon' (PNG only);Other file icon;Cover (front);Cover (back);Leaflet page;Media (e.g. lable side of CD);Lead artist/lead performer/soloist;Artist/performer;Conductor;Band/Orchestra;Composer;Lyricist/text writer;Recording Location;During recording;During performance;Movie/video screen capture;A bright coloured fish;Illustration;Band/artist logotype;Publisher/Studio logotype".split(";");
ID3v2.b.APIC=function(b,c,d,a,f){f=f||"3";a=b;var v=h(d.a(b));switch(f){case "2":var l=d.c(b+1,3);b+=4;break;case "3":case "4":l=d.e(b+1,c-(b-a),""),b+=1+l.g}f=d.a(b,1);f=g[f];v=d.e(b+1,c-(b-a),v);b+=1+v.g;return{format:l.toString(),type:f,description:v.toString(),data:d.l(b,a+c-b)}};ID3v2.b.COMM=function(b,c,d){var a=b,f=h(d.a(b)),g=d.c(b+1,3),l=d.e(b+4,c-4,f);b+=4+l.g;b=d.e(b,a+c-b,f);return{language:g,O:l.toString(),text:b.toString()}};ID3v2.b.COM=ID3v2.b.COMM;ID3v2.b.PIC=function(b,c,d,a){return ID3v2.b.APIC(b,
c,d,a,"2")};ID3v2.b.PCNT=function(b,c,d){return d.J(b)};ID3v2.b.CNT=ID3v2.b.PCNT;ID3v2.b["T*"]=function(b,c,d){var a=h(d.a(b));return d.e(b+1,c-1,a).toString()};ID3v2.b.TCON=function(b,c,d){return ID3v2.b["T*"].apply(this,arguments).replace(/^\(\d+\)/,"")};ID3v2.b.TCO=ID3v2.b.TCON;ID3v2.b.USLT=function(b,c,d){var a=b,f=h(d.a(b)),g=d.c(b+1,3),l=d.e(b+4,c-4,f);b+=4+l.g;b=d.e(b,a+c-b,f);return{language:g,G:l.toString(),L:b.toString()}};ID3v2.b.ULT=ID3v2.b.USLT})();(function(h){function g(b,a,f,h){var l=b.i(a);if(0==l)h();else{var e=b.c(a+4,4);-1<["moov","udta","meta","ilst"].indexOf(e)?("meta"==e&&(a+=4),b.f([a+8,a+8+8],function(){g(b,a+8,l-8,h)})):b.f([a+(e in c.j?0:l),a+l+8],function(){g(b,a+l,f,h)})}}function b(d,a,f,g,h){h=void 0===h?"":h+" ";for(var e=f;e<f+g;){var t=a.i(e);if(0==t)break;var m=a.c(e+4,4);if(-1<["moov","udta","meta","ilst"].indexOf(m)){"meta"==m&&(e+=4);b(d,a,e+8,t-8,h);break}if(c.j[m]){var u=a.o(e+16+1),k=c.j[m],u=c.types[u];if("trkn"==
m)d[k[0]]=a.a(e+16+11),d.count=a.a(e+16+13);else{var m=e+16+4+4,r=t-16-4-4,n;switch(u){case "text":n=a.e(m,r,"UTF-8");break;case "uint8":n=a.w(m);break;case "jpeg":case "png":n={k:"image/"+u,data:a.l(m,r)}}d[k[0]]="comment"===k[0]?{text:n}:n}}e+=t}}var c=h.r={};c.types={0:"uint8",1:"text",13:"jpeg",14:"png",21:"uint8"};c.j={"\u00a9alb":["album"],"\u00a9art":["artist"],"\u00a9ART":["artist"],aART:["artist"],"\u00a9day":["year"],"\u00a9nam":["title"],"\u00a9gen":["genre"],trkn:["track"],"\u00a9wrt":["composer"],
"\u00a9too":["encoder"],cprt:["copyright"],covr:["picture"],"\u00a9grp":["grouping"],keyw:["keyword"],"\u00a9lyr":["lyrics"],"\u00a9cmt":["comment"],tmpo:["tempo"],cpil:["compilation"],disk:["disc"]};c.m=function(b,a){b.f([0,7],function(){g(b,0,b.h(),a)})};c.n=function(c){var a={};b(a,c,0,c.h());return a};h.ID4=h.r})(this);

View File

@ -1,28 +1,30 @@
{% extends 'base.html' %}
{% extends 'marietje/base.html' %}
{% load static %}
{% block title %}Manage{% endblock %}
{% block content %}
<form action="/songs/edit/{{ song.id }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="row centered-form">
<div class="col-xs-12 col-sm-8 col-md-4 col-sm-offset-2 col-md-offset-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Edit Song</h3>
</div>
<div class="panel-body">
<div class="form-group">
<input type="text" id="artist" name="artist" class="form-control input-sm" placeholder="Artist" value="{{ song.artist }}">
<div class="container">
<div class="row mt-5">
<div class="col-lg-4 mx-auto">
<div class="card">
<div class="card-header">
<h3>Edit Song</h3>
</div>
<div class="form-group">
<input type="text" id="title" name="title" class="form-control input-sm" placeholder="Title" value="{{ song.title }}">
<div class="card-body">
<form method="POST" action="/songs/edit/{{ song.id }}/" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-group mb-3">
<input type="text" id="artist" name="artist" class="form-control input-sm" placeholder="Artist" value="{{ song.artist }}">
</div>
<div class="form-group mb-3">
<input type="text" id="title" name="title" class="form-control input-sm" placeholder="Title" value="{{ song.title }}">
</div>
<input type="submit" value="Save" class="btn btn-primary btn-block w-100">
</form>
</div>
<input type="submit" value="Save" class="btn btn-primary btn-block">
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -1,47 +1,173 @@
{% extends 'base.html' %}
{% extends 'marietje/base.html' %}
{% load static %}
{% block title %}Manage{% endblock %}
{% block content %}
<div class="table-responsive">
<table id="request-table" class="table table-striped">
<thead>
<tr>
<th>Artist</th>
<th>Title</th>
</tr>
<tr>
<th><input id="search-artist" class="search-input" type="text"></th>
<th><input id="search-title" class="search-input" type="text"></th>
</tr>
</thead>
<tfoot>
<tr>
<th colspan="3" class="ts-pager form-horizontal">
<button type="button" class="btn first"><i class="icon-step-backward glyphicon glyphicon-step-backward"></i></button>
<button type="button" class="btn prev"><i class="icon-arrow-left glyphicon glyphicon-backward"></i></button>
<button type="button" class="btn next"><i class="icon-arrow-right glyphicon glyphicon-forward"></i></button>
<button type="button" class="btn last"><i class="icon-step-forward glyphicon glyphicon-step-forward"></i></button>
<select class="pagesize input-mini" title="Select page size">
<option selected value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<select class="pagenum input-mini" title="Select page number"></select>
</th>
</tr>
</tfoot>
<tbody>
</tbody>
</table>
</div>
<script type="text/javascript" src="{% static 'js/js.cookie-2.1.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/manage.js' %}"></script>
<script type="text/javascript">
var csrf_token = "{{ csrf_token }}";
</script>
<div class="container">
<div class="table-responsive mt-5">
<table id="request-table" class="table table-striped">
<thead>
<tr>
<th>Artist</th>
<th>Title</th>
</tr>
<tr>
<th colspan="2"><input id="search-all" class="search-input" type="text" v-model="search_input"/></th>
</tr>
</thead>
<tfoot>
<tr>
<th colspan="2" class="ts-pager form-horizontal">
<button v-if="page_number === 1" type="button" class="btn first disabled"><i class="fa-solid fa-backward-fast"></i></button>
<button v-else v-on:click="update_page(1);" type="button" class="btn first"><i class="fa-solid fa-backward-fast"></i></button>
<button v-if="page_number === 1" type="button" class="btn prev disabled"><i class="fa-solid fa-backward"></i></button>
<button v-else v-on:click="update_page(page_number - 1);" type="button" class="btn prev"><i class="fa-solid fa-backward"></i></button>
<button v-if="page_number === number_of_pages" type="button" class="btn next disabled"><i class="fa-solid fa-forward"></i></button>
<button v-else v-on:click="update_page(page_number + 1);" type="button" class="btn next"><i class="fa-solid fa-forward"></i></button>
<button v-if="page_number === number_of_pages" type="button" class="btn last disabled"><i class="fa-solid fa-forward-fast"></i></button>
<button v-else v-on:click="update_page(number_of_pages);" type="button" class="btn last"><i class="fa-solid fa-forward-fast"></i></button>
<select class="pagesize input-mini" title="Select page size" v-model="page_size">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<select class="pagenum input-mini" title="Select page number" v-model="page_number">
<template v-for="(i, index) in number_of_pages">
<option :value="i"><% i %></option>
</template>
</select>
</th>
<th colspan="2"></th>
</tr>
</tfoot>
<tbody>
<template v-for="(song, index) in songs">
<tr>
<td>
<% song.artist %>
</td>
<td>
<a :href="'/songs/edit/' + song.id + '/'" v-on:click="request_song(song.id);"><% song.title %></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<script>
let manage_vue = new Vue({
el: '#request-table',
delimiters: ['<%', '%>'],
data: {
songs: [],
total_songs: 0,
search_input: "",
typing_timer: null,
page_size: 10,
page_number: 1,
},
watch: {
search_input: {
handler(val, oldVal) {
clearTimeout(this.typing_timer);
if (this.search !== "") {
this.typing_timer = setTimeout(this.search, 200);
}
}
},
page_number: {
handler(val, oldVal) {
if (this.page_number <= 0) {
this.page_number = 1;
}
if (this.page_number > this.number_of_pages) {
this.page_number = this.number_of_pages;
}
this.refresh();
}
},
page_size: {
handler(val, oldVal) {
if (this.page_size <= 0) {
this.page_size = 10;
}
this.page_number = 1;
this.refresh();
}
}
},
computed: {
number_of_pages: function() {
return Math.ceil(this.total_songs / this.page_size);
}
},
created() {
fetch(
`/api/v1/songs/?ordering=title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}`
).then(response => {
if (response.status === 200) {
return response.json();
} else {
throw response;
}
}).then(data => {
this.songs = data.results;
this.total_songs = data.count;
}).catch((e) => {
if (e instanceof Response) {
e.json().then(data => {
tata.error("", data.errorMessage);
});
} else {
tata.error("", "An unknown error occurred, please try again.")
}
});
},
methods: {
search() {
this.page_number = 1;
this.refresh();
},
refresh() {
fetch(
`/api/v1/songs/?ordering=title&limit=${this.page_size}&offset=${this.page_size * (this.page_number - 1)}&search=${this.search_input}`,
{
headers: {
"X-CSRFToken": CSRF_TOKEN,
}
}
).then(response => {
if (response.status === 200) {
return response.json();
} else {
throw response;
}
}).then(data => {
this.songs = data.results;
this.total_songs = data.count;
}).catch((e) => {
if (e instanceof Response) {
e.json().then(data => {
tata.error("", data.errorMessage);
});
} else {
tata.error("", "An unknown error occurred, please try again.")
}
});
},
update_page(page_number) {
this.page_number = page_number;
}
}
});
</script>
{% endblock %}

View File

@ -1,58 +1,218 @@
{% extends 'base.html' %}
{% extends 'marietje/base.html' %}
{% load static %}
{% block title %}Upload{% endblock %}
{% block content %}
<div class="row">
<div class="col-xs-12 col-sm-10 col-md-6 col-sm-offset-1 col-md-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Upload</h3>
</div>
<div class="panel-body ">
<div class="forms-container">
<div class="panel panel-default uploadform">
<div class="panel-body">
<form action="/api/upload" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="fileupload fileupload-new" data-provides="fileupload">
<span class="btn btn-primary btn-file"><span class="fileupload-new">Select files</span>
<span class="fileupload-exists">Change</span>
<input class="filefield" type="file" name="file[]" accept="audio/*" multiple />
</span>
<br>
</div>
<div class="song-container panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Song</h3>
</div>
<div class="panel-body ">
<div class="form-group">
<input type="text" name="artist[]" class="form-control input-sm artist" placeholder="Artist">
</div>
<div class="form-group">
<input type="text" name="title[]" class="form-control input-sm title" placeholder="Title">
</div>
</div>
</div>
</form>
<div class="container">
<div class="row mt-5">
<div class="col-lg-6 mx-auto">
<div class="card uploadform" id="uploadform">
<form action="{% url "songs:upload" %}" method="POST" enctype="multipart/form-data">
<div class="card-header">
<h3>Upload</h3>
</div>
</div>
<div class="card-body">
{% csrf_token %}
<div class="fileupload fileupload-new" data-provides="fileupload">
<span class="btn btn-primary btn-file">
<span v-if="fileObjects.length === 0">
Select files
</span>
<span v-else>
Change
</span>
<input class="filefield" id="filefield" type="file" name="file[]" accept="audio/*"
multiple @change="set_new_files"/>
</span>
</div>
<div class="songs">
<div v-for="fileObject in fileObjects" class="song-container card mb-3">
<div class="card-header">
<h3><% fileObject.name %></h3>
</div>
<div class="card-body">
<div class="form-group mb-3">
<div v-if="fileObject.artist === '' || fileObject.artist === null" class="alert alert-danger">Please enter an artist for this song.</div>
<input v-if="upload_in_progress || uploaded" type="text" name="artist[]" class="form-control input-sm artist" disabled
placeholder="Artist" v-model="fileObject.artist"/>
<input v-else type="text" name="artist[]" class="form-control input-sm artist"
placeholder="Artist" v-model="fileObject.artist"/>
</div>
<div class="form-group mb-3">
<div v-if="fileObject.title === '' || fileObject.title === null" class="alert alert-danger">Please enter a title for this song.</div>
<input v-if="upload_in_progress || uploaded" type="text" name="title[]" class="form-control input-sm title" disabled
placeholder="Title" v-model="fileObject.title"/>
<input v-else type="text" name="title[]" class="form-control input-sm title"
placeholder="Title" v-model="fileObject.title"/>
</div>
<template v-if="fileObject.upload_finished === true">
<div v-if="fileObject.success === true" class="alert alert-success">Upload finished successfully.</div>
<div v-else class="alert alert-danger"><% fileObject.error_message %></div>
</template>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="progress mt-2 mb-3">
<div :class="{ 'progress-bar-animated': (upload_in_progress), 'bg-success': (uploaded && everything_successfully_uploaded), 'bg-danger': (uploaded && !everything_successfully_uploaded) }" class="progress-bar progress-bar-striped" role="progressbar" :style="{ width: (progress_bar_width + '%') }" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<template v-if="upload_in_progress || uploaded">
<button v-if="uploaded" class="btn btn-primary btn-block w-100" v-on:click="clear">Clear</button>
<button v-else class="btn btn-primary btn-block w-100 disabled">Clear</button>
</template>
<template v-else>
<input v-if="ready_for_upload" id="upload" class="btn btn-primary btn-block w-100" type="submit" value="Upload" v-on:click="upload"/>
<button v-else class="btn btn-primary btn-block w-100 disabled">Upload</button>
</template>
</div>
</form>
</div>
<span class="result-message"></span>
<div class="progress">
<div class="progress-bar progress-bar-info progress-bar-striped" role="progressbar" style="width: 0%;">
</div>
</div>
<button id="upload" class="btn btn-primary btn-block">Upload</button>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="{% static 'songs/css/upload.css' %}"/>
<script type="module">
import * as id3 from '//unpkg.com/id3js@^2/lib/id3.js';
<link rel="stylesheet" href="{% static 'css/upload.css' %}" />
<script type="text/javascript" src="{% static 'js/upload.js' %}"></script>
<script type="text/javascript" src="{% static 'js/id3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/jquery.form.min.js' %}"></script>
let upload_vue = new Vue({
el: '#uploadform',
delimiters: ['<%', '%>'],
data: {
files: [],
fileObjects: [],
uploaded: false,
upload_in_progress: false,
},
computed: {
ready_for_upload: function() {
if (this.uploaded !== false || this.upload_in_progress !== false || this.fileObjects.length === 0) {
return false;
} else {
for (let i = 0; i < this.fileObjects.length; i++) {
if (this.fileObjects[i].artist === null || this.fileObjects[i].artist === '' || this.fileObjects[i].title === null || this.fileObjects[i].title === '') {
return false;
}
}
return true;
}
},
everything_successfully_uploaded: function() {
return this.fileObjects.map((fileObject) => {
return fileObject.upload_finished === true && fileObject.success === true;
}).reduce((previousValue, currentValue) => {
return previousValue && currentValue;
}, true);
},
progress_bar_width: function() {
if (this.fileObjects.length === 0) {
return 0;
}
const files_uploaded_successfully = this.fileObjects.map((fileObject) => {
if (fileObject.upload_finished === true && fileObject.success === true) {
return 1;
} else {
return 0;
}
}).reduce((previousValue, currentValue) => {
return previousValue + currentValue;
}, 0);
return Math.round((files_uploaded_successfully / this.fileObjects.length) * 100);
}
},
methods: {
clear(event) {
event.preventDefault();
this.files = [];
this.fileObjects = [];
this.uploaded = false;
this.upload_in_progress = false;
},
upload(event) {
this.upload_in_progress = true;
event.preventDefault();
let allPromises = [];
for (let i = 0; i < this.fileObjects.length; i++) {
const current_file = this.fileObjects[i].file;
const current_artist = this.fileObjects[i].artist;
const current_title = this.fileObjects[i].title;
let data = new FormData();
data.append('file', current_file);
data.append('artist', current_artist);
data.append('title', current_title);
allPromises.push(fetch('/api/v1/songs/upload/', {
method: 'POST',
headers: {
"X-CSRFToken": CSRF_TOKEN,
},
body: data,
}).then(result => {
if (result.status === 200) {
return result.json();
} else {
throw result;
}
}).then(() => {
this.fileObjects[i].success = true;
}).catch(e => {
if (e instanceof Response) {
e.json().then(data => {
this.fileObjects.error_message = data.errorMessage;
this.fileObjects.success = false;
});
} else {
this.fileObjects.error_message = "An exception occurred while uploading this file, please try again.";
this.fileObjects.success = false;
}
}).finally(() => {
this.fileObjects[i].upload_finished = true;
}));
}
Promise.all(allPromises).finally(() => {
this.upload_in_progress = false;
this.uploaded = true;
});
},
async set_new_files(event) {
this.files = event.target.files;
let newFileObjects = [];
for (let i = 0; i < this.files.length; i++) {
try {
const tags = await this.parseSong(this.files[i]);
newFileObjects.push(
{
"file": this.files[i],
"name": this.files[i].name,
"artist": tags.artist,
"title": tags.title,
"success": null,
"error_message": null,
"upload_finished": false,
}
);
} catch {
newFileObjects.push(
{
"file": this.files[i],
"name": this.files[i].name,
"artist": "",
"title": "",
"success": null,
"error_message": null,
"upload_finished": false,
}
)
}
}
this.fileObjects = newFileObjects;
},
parseSong(file) {
return id3.fromFile(file);
}
}
});
</script>
{% endblock %}

View File

@ -1,11 +1,11 @@
from django.conf.urls import url
from django.urls import path
from . import views
app_name = 'songs'
app_name = "songs"
urlpatterns = [
url(r'^upload/', views.upload, name='upload'),
url(r'^manage/', views.manage, name='manage'),
url(r'^edit/([0-9]+)', views.edit),
path("upload/", views.UploadView.as_view(), name="upload"),
path("manage/", views.ManageView.as_view(), name="manage"),
path("edit/<int:song_id>/", views.EditView.as_view(), name="edit"),
]

View File

@ -1,28 +1,89 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from .counters import upload_counter
from .models import Song
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from .services import check_upload_stats, get_reputation, upload_file, UploadException
@login_required
def upload(request):
return render(request, 'songs/upload.html')
class UploadView(LoginRequiredMixin, TemplateView):
template_name = "songs/upload.html"
def post(self, request, **kwargs):
files = request.FILES.getlist("file[]")
artists = request.POST.getlist("artist[]")
titles = request.POST.getlist("title[]")
for artist in artists:
if not artist:
messages.add_message(
request, messages.ERROR, "Please enter artists which are not empty.", extra_tags="danger"
)
return render(request, self.template_name)
for title in titles:
if not title:
messages.add_message(
request, messages.ERROR, "Please enter titles which are not empty.", extra_tags="danger"
)
return render(request, self.template_name)
if not check_upload_stats(request.user):
reputation = get_reputation(request.user)
msg = (
"Queue-to-upload ratio too low. Please queue more during regular opening hours to improve the "
"ratio. (Ratio: {} ≱ 1.00)"
)
messages.add_message(request, messages.ERROR, msg.format(reputation), extra_tags="danger")
return render(request, self.template_name)
uploaded_correctly = 0
for i, file in enumerate(files):
try:
upload_file(file, artists[i], titles[i], request.user)
uploaded_correctly += 1
except UploadException:
messages.add_message(
request,
messages.ERROR,
"File {} could not be uploaded due to an exception that "
"occurred while contacting the file server, please try "
"again.".format(titles[i]),
extra_tags="danger",
)
if uploaded_correctly > 0:
messages.add_message(
request,
messages.SUCCESS,
"{}/{} files uploaded.".format(uploaded_correctly, len(files)),
extra_tags="success",
)
upload_counter.inc()
return render(request, self.template_name)
@login_required
def manage(request):
return render(request, 'songs/manage.html')
class ManageView(LoginRequiredMixin, TemplateView):
template_name = "songs/manage.html"
@login_required
def edit(request, song_id):
song = get_object_or_404(Song, pk=song_id, user=request.user)
if not request.POST:
return render(request, 'songs/edit.html', {'song': song})
class EditView(LoginRequiredMixin, TemplateView):
template_name = "songs/edit.html"
# Save data.
artist = request.POST.get('artist')
title = request.POST.get('title')
song.artist = artist
song.title = title
song.save()
return redirect('songs:manage')
def get(self, request, **kwargs):
song_id = kwargs.get("song_id")
song = get_object_or_404(Song, pk=song_id, user=request.user)
return render(request, self.template_name, {"song": song})
def post(self, request, **kwargs):
song_id = kwargs.get("song_id")
song = get_object_or_404(Song, pk=song_id, user=request.user)
artist = request.POST.get("artist")
title = request.POST.get("title")
song.artist = artist
song.title = title
song.save()
return redirect("songs:manage")