mirror of
https://gitlab.science.ru.nl/technicie/MarietjeDjango.git
synced 2025-12-10 07:52:20 +01:00
Marietje 4.1: Addition of Django REST framework, Swagger, Dark mode and updates to Django and Bootstrap
This commit is contained in:
@ -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):
|
||||
|
||||
24
marietje/songs/api/v1/serializers.py
Normal file
24
marietje/songs/api/v1/serializers.py
Normal 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",
|
||||
]
|
||||
10
marietje/songs/api/v1/urls.py
Normal file
10
marietje/songs/api/v1/urls.py
Normal 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"),
|
||||
]
|
||||
96
marietje/songs/api/v1/views.py
Normal file
96
marietje/songs/api/v1/views.py
Normal 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.",
|
||||
},
|
||||
)
|
||||
@ -2,4 +2,5 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class SongsConfig(AppConfig):
|
||||
name = 'songs'
|
||||
name = "songs"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
4
marietje/songs/counters.py
Normal file
4
marietje/songs/counters.py
Normal 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")
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
68
marietje/songs/services.py
Normal file
68
marietje/songs/services.py
Normal 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)
|
||||
27
marietje/songs/static/songs/css/upload.css
Normal file
27
marietje/songs/static/songs/css/upload.css
Normal 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;
|
||||
}
|
||||
25
marietje/songs/static/songs/js/id3.min.js
vendored
Normal file
25
marietje/songs/static/songs/js/id3.min.js
vendored
Normal 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);
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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")
|
||||
|
||||
Reference in New Issue
Block a user