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

@ -7,16 +7,17 @@ admin.site.register(Playlist)
@admin.register(Queue)
class OrderAdmin(admin.ModelAdmin):
list_display = ('name', 'playlist', 'random_playlist')
list_display = ("name", "playlist", "random_playlist")
@admin.register(PlaylistSong)
class PlaylistSongAdmin(admin.ModelAdmin):
list_display = ('playlist', 'song', 'user', 'state', 'played_at')
list_display_links = ('song',)
list_filter = ('playlist', 'state', 'user')
search_fields = ('song__title', 'song__artist', 'user__name')
autocomplete_fields = ('user',)
readonly_fields = ('song',)
list_display = ("playlist", "song", "user", "state", "played_at")
list_display_links = ("song",)
list_filter = ("playlist", "state", "user")
search_fields = ("song__title", "song__artist", "user__name")
autocomplete_fields = ("user",)
readonly_fields = ("song",)
admin.site.register(QueueCommand)

View File

View File

@ -0,0 +1,50 @@
import time
from rest_framework import serializers
from marietje.api.v1.serializers import UserRelatedFieldSerializer
from queues.models import Queue, Playlist, PlaylistSong
from songs.api.v1.serializers import SongSerializer
class PlaylistSongSerializer(serializers.ModelSerializer):
song = SongSerializer()
user = UserRelatedFieldSerializer()
class Meta:
model = PlaylistSong
fields = ["id", "playlist", "song", "user", "played_at"]
class PlaylistSerializer(serializers.ModelSerializer):
songs = PlaylistSongSerializer(many=True)
class Meta:
model = Playlist
fields = [
"id",
"songs",
]
class QueueSerializer(serializers.ModelSerializer):
current_song = serializers.SerializerMethodField()
queue = serializers.SerializerMethodField()
def get_current_song(self, queue):
return PlaylistSongSerializer(queue.current_song()).data
def get_queue(self, queue):
return PlaylistSongSerializer(queue.queue(), many=True).data
class Meta:
model = Queue
fields = [
"id",
"name",
"playlist",
"random_playlist",
"current_song",
"queue",
"started_at",
]

View File

@ -0,0 +1,27 @@
from django.urls import path
from queues.api.v1.views import (
PlaylistListAPIView,
PlaylistRetrieveAPIView,
QueueAPIView,
QueueSkipAPIView,
PlaylistSongMoveDownAPIView,
PlaylistSongCancelAPIView,
QueueRequestAPIView,
QueueVolumeDownAPIView,
QueueVolumeUpAPIView,
QueueMuteAPIView,
)
urlpatterns = [
path("current/", QueueAPIView.as_view(), name="queue_current"),
path("current/skip/", QueueSkipAPIView.as_view(), name="queue_skip"),
path("current/request/", QueueRequestAPIView.as_view(), name="queue_request"),
path("current/volume-down/", QueueVolumeDownAPIView.as_view(), name="queue_volume_down"),
path("current/volume-up/", QueueVolumeUpAPIView.as_view(), name="queue_volume_up"),
path("current/mute/", QueueMuteAPIView.as_view(), name="queue_mute"),
path("playlists/", PlaylistListAPIView.as_view(), name="playlist_list"),
path("playlists/<int:pk>/", PlaylistRetrieveAPIView.as_view(), name="playlist_retrieve"),
path("playlist-song/<int:id>/move-down/", PlaylistSongMoveDownAPIView.as_view(), name="playlist_song_move_down"),
path("playlist-song/<int:id>/cancel/", PlaylistSongCancelAPIView.as_view(), name="playlist_song_cancel"),
]

View File

@ -0,0 +1,262 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView, get_object_or_404, CreateAPIView, DestroyAPIView
from rest_framework.views import APIView
from rest_framework.response import Response
from marietje.api.openapi import CustomAutoSchema
from marietje.api.permissions import IsAuthenticatedOrTokenHasScopeForMethod
from django.http import Http404
from queues.api.v1.serializers import PlaylistSerializer, QueueSerializer, PlaylistSongSerializer
from queues.exceptions import RequestException
from queues.models import Playlist, PlaylistSong, QueueCommand
from queues.services import get_user_or_default_queue
from songs.counters import request_counter
from songs.models import Song
class PlaylistListAPIView(ListAPIView):
serializer_class = PlaylistSerializer
queryset = Playlist.objects.all()
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {"GET": ["read"]}
class PlaylistRetrieveAPIView(RetrieveAPIView):
serializer_class = PlaylistSerializer
queryset = Playlist.objects.all()
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {"GET": ["read"]}
class QueueAPIView(APIView):
serializer_class = QueueSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {"GET": ["read"]}
schema = CustomAutoSchema(
response_schema={
"type": "object",
"properties": {
"id": {"type": "int", "example": 1},
"name": {"type": "string", "example": "string"},
"playlist": {"type": "int", "example": 1},
"random_playlist": {"type": "int", "example": 1},
"current_song": {"$ref": "#/components/schemas/PlaylistSong"},
"queue": {"type": "array", "items": {"$ref": "#/components/schemas/PlaylistSong"}},
"started_at": {"type": "string", "format": "date-time", "nullable": True},
},
}
)
def get(self, request, **kwargs):
queue = get_user_or_default_queue(request)
if queue is None:
raise Http404()
return Response(status=200, data=self.serializer_class(queue).data)
class QueueSkipAPIView(APIView):
serializer_class = QueueSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"POST": ["write"],
}
schema = CustomAutoSchema(
response_schema={
"type": "object",
"properties": {
"id": {"type": "int", "example": 1},
"name": {"type": "string", "example": "string"},
"playlist": {"type": "int", "example": 1},
"random_playlist": {"type": "int", "example": 1},
"current_song": {"$ref": "#/components/schemas/PlaylistSong"},
"queue": {"type": "array", "items": {"$ref": "#/components/schemas/PlaylistSong"}},
"started_at": {"type": "string", "format": "date-time", "nullable": True},
},
}
)
def post(self, request, **kwargs):
queue = get_user_or_default_queue(request)
if queue is None:
return Response(status=404)
playlist_song = request.user.queue.current_song()
if (
request.user is not None
and playlist_song.user != request.user
and not request.user.has_perm("queues.can_skip")
):
return Response(status=403)
playlist_song.state = 2
playlist_song.save()
return Response(status=200, data=QueueSerializer(queue).data)
class PlaylistSongMoveDownAPIView(APIView):
serializer_class = PlaylistSongSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"PATCH": ["write"],
}
schema = CustomAutoSchema(response_schema={"$ref": "#/components/schemas/PlaylistSong"})
def patch(self, request, **kwargs):
playlist_song_id = kwargs.get("id")
playlist_song = get_object_or_404(PlaylistSong, id=playlist_song_id)
if (
request.user is not None
and playlist_song.user != request.user
and not request.user.has_perm("queues.can_move")
):
return Response(status=403)
playlist_song.move_down()
return Response(status=200, data=self.serializer_class(playlist_song).data)
class PlaylistSongCancelAPIView(DestroyAPIView):
serializer_class = PlaylistSongSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"DELETE": ["write"],
}
def delete(self, request, **kwargs):
playlist_song_id = kwargs.get("id")
playlist_song = get_object_or_404(PlaylistSong, id=playlist_song_id)
if (
request.user is not None
and playlist_song.user != request.user
and not request.user.has_perm("queues.can_cancel")
):
return Response(status=403)
playlist_song.delete()
return Response(status=200, data=self.serializer_class(playlist_song).data)
class QueueRequestAPIView(CreateAPIView):
serializer_class = PlaylistSongSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"POST": ["write"],
}
schema = CustomAutoSchema(
request_schema={
"type": "object",
"properties": {
"song": {"type": "int", "example": 1},
},
}
)
def post(self, request, **kwargs):
queue = get_user_or_default_queue(request)
if queue is None:
return Response(status=404)
song_id = request.data.get("song", None)
if song_id is None:
return Response(status=400, data={"success": False, "errorMessage": "Song ID not set."})
song = get_object_or_404(Song, id=song_id, deleted=False)
try:
playlist_song = queue.request(song, request.user)
except RequestException as e:
return Response(data={"success": False, "errorMessage": str(e)})
request_counter.labels(queue=queue.name).inc()
return Response(status=200, data=self.serializer_class(playlist_song).data)
class QueueVolumeDownAPIView(APIView):
serializer_class = QueueSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"POST": ["write"],
}
schema = CustomAutoSchema(
response_schema={
"type": "object",
"properties": {
"id": {"type": "int", "example": 1},
"name": {"type": "string", "example": "string"},
"playlist": {"type": "int", "example": 1},
"random_playlist": {"type": "int", "example": 1},
"current_song": {"$ref": "#/components/schemas/PlaylistSong"},
"queue": {"type": "array", "items": {"$ref": "#/components/schemas/PlaylistSong"}},
"started_at": {"type": "string", "format": "date-time", "nullable": True},
},
}
)
def post(self, request, **kwargs):
queue = get_user_or_default_queue(request)
if queue is None:
return Response(status=404)
if request.user is not None and not request.user.has_perm("queues.can_control_volume"):
return Response(status=403)
QueueCommand.objects.create(queue=queue, command="volume_down")
return Response(status=200, data=self.serializer_class(queue).data)
class QueueVolumeUpAPIView(APIView):
serializer_class = QueueSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"POST": ["write"],
}
schema = CustomAutoSchema(
response_schema={
"type": "object",
"properties": {
"id": {"type": "int", "example": 1},
"name": {"type": "string", "example": "string"},
"playlist": {"type": "int", "example": 1},
"random_playlist": {"type": "int", "example": 1},
"current_song": {"$ref": "#/components/schemas/PlaylistSong"},
"queue": {"type": "array", "items": {"$ref": "#/components/schemas/PlaylistSong"}},
"started_at": {"type": "string", "format": "date-time", "nullable": True},
},
}
)
def post(self, request, **kwargs):
queue = get_user_or_default_queue(request)
if queue is None:
return Response(status=404)
if request.user is not None and not request.user.has_perm("queues.can_control_volume"):
return Response(status=403)
QueueCommand.objects.create(queue=queue, command="volume_up")
return Response(status=200, data=self.serializer_class(queue).data)
class QueueMuteAPIView(APIView):
serializer_class = QueueSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
required_scopes_for_method = {
"POST": ["write"],
}
schema = CustomAutoSchema(
response_schema={
"type": "object",
"properties": {
"id": {"type": "int", "example": 1},
"name": {"type": "string", "example": "string"},
"playlist": {"type": "int", "example": 1},
"random_playlist": {"type": "int", "example": 1},
"current_song": {"$ref": "#/components/schemas/PlaylistSong"},
"queue": {"type": "array", "items": {"$ref": "#/components/schemas/PlaylistSong"}},
"started_at": {"type": "string", "format": "date-time", "nullable": True},
},
}
)
def post(self, request, **kwargs):
queue = get_user_or_default_queue(request)
if queue is None:
return Response(status=404)
if request.user is not None and not request.user.has_perm("queues.can_control_volume"):
return Response(status=403)
QueueCommand.objects.create(queue=queue, command="mute")
return Response(status=200, data=self.serializer_class(queue).data)

View File

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

View File

@ -0,0 +1,2 @@
class RequestException(Exception):
pass

View File

@ -0,0 +1,33 @@
# Generated by Django 4.1.5 on 2023-01-04 09:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('queues', '0009_unlimited_queue_length_perm'),
]
operations = [
migrations.AlterField(
model_name='playlist',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='playlistsong',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='queue',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='queuecommand',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.1.5 on 2023-01-04 16:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('queues', '0010_alter_playlist_id_alter_playlistsong_id_and_more'),
]
operations = [
migrations.AlterField(
model_name='playlistsong',
name='playlist',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='songs', to='queues.playlist'),
),
]

View File

@ -3,11 +3,13 @@ from django.db.models import Q
from django.conf import settings
from django.utils import timezone
from queues.exceptions import RequestException
from songs.models import Song
class Playlist(models.Model):
def __str__(self):
return 'Playlist #' + str(self.id)
return "Playlist #" + str(self.id)
class PlaylistSong(models.Model):
@ -16,6 +18,7 @@ class PlaylistSong(models.Model):
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="songs",
)
song = models.ForeignKey(
Song,
@ -37,14 +40,13 @@ class PlaylistSong(models.Model):
# 2: Played.
# 3: Cancelled.
STATECHOICE = (
(0, 'Queued'),
(1, 'Playing'),
(2, 'Played'),
(3, 'Cancelled'),
(0, "Queued"),
(1, "Playing"),
(2, "Played"),
(3, "Cancelled"),
)
state = models.IntegerField(default=0, db_index=True, choices=STATECHOICE)
def move_down(self):
other_song = PlaylistSong.objects.filter(playlist=self.playlist, id__gt=self.id).first()
old_id = self.id
@ -54,17 +56,17 @@ class PlaylistSong(models.Model):
other_song.save()
def __str__(self):
return 'Playlist #' + str(self.playlist_id) + ': ' + str(self.song)
return "Playlist #" + str(self.playlist_id) + ": " + str(self.song)
class Queue(models.Model):
class Meta:
permissions = (
('can_skip', 'Can skip the currently playing song'),
('can_move', 'Can move all songs in the queue'),
('can_cancel', 'Can cancel all songs in the queue'),
('can_control_volume', 'Can control the volume of Marietje'),
('unlimited_queue_length', 'Is unlimited by maximum queue length'),
("can_skip", "Can skip the currently playing song"),
("can_move", "Can move all songs in the queue"),
("can_cancel", "Can cancel all songs in the queue"),
("can_control_volume", "Can control the volume of Marietje"),
("unlimited_queue_length", "Is unlimited by maximum queue length"),
)
name = models.TextField()
@ -75,35 +77,26 @@ class Queue(models.Model):
null=True,
)
random_playlist = models.ForeignKey(
Playlist,
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name='random_playlist_set'
Playlist, on_delete=models.SET_NULL, blank=True, null=True, related_name="random_playlist_set"
)
started_at = models.DateTimeField(blank=True, null=True)
songs = None
player_token = models.TextField(blank=True, null=True)
def get_songs(self):
if self.songs is None:
self.fill_random_queue()
self.songs = PlaylistSong.objects\
.filter(Q(playlist=self.playlist_id) | Q(playlist_id=self.random_playlist_id),
Q(state=0) | Q(state=1))\
.order_by('-state', 'playlist_id', 'id')\
.select_related('song', 'user')
return self.songs
self.fill_random_queue()
return (
PlaylistSong.objects.filter(
Q(playlist=self.playlist_id) | Q(playlist_id=self.random_playlist_id), Q(state=0) | Q(state=1)
)
.order_by("-state", "playlist_id", "id")
.select_related("song", "user")
)
def current_song(self):
songs = self.get_songs()
if not songs:
return None
song = songs[0]
if song.state != 1:
song.state = 1
song.save()
return song
return songs[0]
def queue(self):
songs = self.get_songs()
@ -112,37 +105,38 @@ class Queue(models.Model):
return songs[1:]
def request(self, song, user):
if not user.has_perm('queues.unlimited_queue_length'):
playlist_songs = PlaylistSong.objects.filter(playlist=self.playlist, state=0).order_by('id')
if user is not None and not user.has_perm("queues.unlimited_queue_length"):
playlist_songs = PlaylistSong.objects.filter(playlist=self.playlist, state=0).order_by("id")
seconds_in_a_row = sum(ps.song.duration for ps in playlist_songs if ps.user == user)
msg = 'You cannot request more than ' + str(settings.MAX_MINUTES_IN_A_ROW) + ' minutes in a row.'
msg = "You cannot request more than " + str(settings.MAX_MINUTES_IN_A_ROW) + " minutes in a row."
if settings.LIMIT_ALWAYS:
if seconds_in_a_row > settings.MAX_MINUTES_IN_A_ROW * 60:
return msg
raise RequestException(msg)
else:
now = timezone.now()
if seconds_in_a_row > 0 and \
seconds_in_a_row + song.duration > settings.MAX_MINUTES_IN_A_ROW * 60 and \
settings.LIMIT_HOURS[0] <= now.hour < settings.LIMIT_HOURS[1]:
return msg
if (
seconds_in_a_row > 0
and seconds_in_a_row + song.duration > settings.MAX_MINUTES_IN_A_ROW * 60
and settings.LIMIT_HOURS[0] <= now.hour < settings.LIMIT_HOURS[1]
):
raise RequestException(msg)
if {ps for ps in playlist_songs if ps.song == song}:
return 'This song is already in the queue.'
raise RequestException("This song is already in the queue.")
playlist_song = PlaylistSong(playlist=self.playlist, song=song, user=user)
playlist_song.save()
playlist_song = PlaylistSong.objects.create(playlist=self.playlist, song=song, user=user)
# If the song was auto-queue'd, then remove it from the auto-queue
autolist_songs = PlaylistSong.objects.filter(playlist=self.random_playlist, state=0, song=song)
autolist_songs.delete()
return None
return playlist_song
def fill_random_queue(self):
song_count = PlaylistSong.objects.filter(playlist_id=self.random_playlist_id, state=0).count()
while song_count < 5:
song = Song.objects.filter(deleted=False).order_by('?').first()
song = Song.objects.filter(deleted=False).order_by("?").first()
if song is None:
return
playlist_song = PlaylistSong(playlist=self.random_playlist, song=song, user=None)

View File

@ -0,0 +1,15 @@
from queues.models import Queue
from django.conf import settings
def get_user_or_default_queue(request):
"""Get the user or default queue."""
if request.user is None:
return get_default_queue()
else:
return request.user.queue
def get_default_queue():
"""Get the default queue."""
return Queue.objects.get(pk=settings.DEFAULT_QUEUE)

View File

@ -1,123 +1,634 @@
{% extends "base.html" %}
{% extends "marietje/base.html" %}
{% load static %}
{% block title %}Queue{% endblock %}
{% block content %}
<nav class="navbar navbar-expand navbar-default navbar-light border-bottom">
<div class="container">
<ul class="nav nav-pills" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="queue-tab" data-bs-toggle="tab" data-bs-target="#queue"
type="button" role="tab" aria-controls="queue" aria-selected="true">Queue
</button>
</li>
<li class="nav-item me-3" role="presentation">
<button class="nav-link" id="request-tab" data-bs-toggle="tab" data-bs-target="#request"
type="button" role="tab" aria-controls="request" aria-selected="false">Request
</button>
</li>
<li id="infobar-buttons" class="nav-item d-flex justify-content-center align-items-center">
{% if perms.queues.can_control_volume %}
<button type="button" id="mute" class="btn nav-btn btn-sm block-button" onclick="mute();">
<i class="fa-solid fa-volume-xmark"></i>
</button>
<button type="button" id="volume_down" class="btn navbar-btn btn-sm block-button"
onclick="volume_down();">
<i class="fa-solid fa-volume-low"></i>
</button>
<button type="button" id="volume_up" class="btn navbar-btn btn-sm block-button"
onclick="volume_up();">
<i class="fa-solid fa-volume-high"></i>
</button>
{% endif %}
{% if perms.queues.can_skip %}
<button type="button" id="skip" class="btn navbar-btn btn-sm block-button" onclick="skip();">
<i class="fa-solid fa-forward-fast"></i>
</button>
{% endif %}
</li>
</ul>
<ul class="navbar-nav navbar-right hidden-xs">
<li class="nav-item me-3">
<p class="navbar-text mb-0 start-queue hidden-sm hidden-xs"></p>
</li>
<li class="nav-item me-3">
<p class="navbar-text mb-0 end-queue"></p>
</li>
<li class="nav-item">
<p class="navbar-text mb-0 duration-queue"></p>
</li>
</ul>
</div>
</nav>
<div class="container">
<br><br>
<div class="alert-location">
</div>
<div class="tab-content">
<div class="tab-pane fade show active" id="queue" role="tabpanel" aria-labelledby="queue-tab">
<div id="queue-container">
<table class="table table-striped">
<thead>
<tr class="table-header-style">
<td class="col-md-4">Artist</td>
<td class="col-md-4">Title</td>
<td class="col-md-2 d-sm-table-cell d-none">Requested By</td>
<td class="col-md-1 text-info d-sm-table-cell d-none" style="cursor: pointer;">
<span v-if="playsIn" id="timeswitch" class="btn btn-link p-0" v-on:click="playsIn = false">Plays In</span>
<span v-else class="btn btn-link p-0" v-on:click="playsIn = true">Plays at</span>
</td>
<td class="col-md-1 control-icons">Control</td>
</tr>
</thead>
<tbody class="queuebody">
<template v-for="(song, index) in queue">
<tr :class="{ marietjequeue: (song.user === null), currentsong: (index === 0), 'fw-bold': (index === 0) }">
<td class="artist"><% song.song.artist %></td>
<td class="title"><% song.song.title %></td>
<td class="d-sm-table-cell d-none requested-by">
<template v-if="song.user === null">
Marietje
</template>
<template v-else>
<% song.user.name %>
</template>
</td>
<td class="d-sm-table-cell d-none plays-at" style="text-align: right">
<template v-if="song.time_until_song_seconds !== null && song.time_until_song_seconds > 0 && playsIn === true">
<% song.time_until_song_seconds.secondsToMMSS() %>
</template>
<template v-else-if="playsIn === false && song.plays_at !== null && song.played === false">
<% song.plays_at.timestampToHHMMSS() %>
</template>
</td>
<td>
<a href="#" v-if="song.can_move_up" v-on:click="move_down(queue[index-1].id)"><i
class="fa-solid fa-arrow-up"></i></a>
<a href="#" v-else class="invisible"><i class="fa-solid fa-arrow-up"></i></a>
<a href="#" v-if="song.can_move_down" v-on:click="move_down(song.id)"><i
class="fa-solid fa-arrow-down"></i></a>
<a href="#" v-else class="invisible"><i class="fa-solid fa-arrow-down"></i></a>
<nav class="navbar navbar-default navbar-fixed-top" style="top: 50px; border-top: 1px solid #bbbbbb; max-height: 50px; box-shadow: 2px 2px 4px #BBBBBB;">
<div class="container">
<ul class="navbar-nav" style="max-height: 50px; margin: 0;">
<li class="btn-toolbar nav navbar-nav" style="margin: 0;">
<button id="request-button" class="btn navbar-btn btn-primary">
Request
</button>
<div id="infobar-buttons" class="btn-group">
{% if perms.queues.can_control_volume %}
<button type="button" id="mute" class="btn navbar-btn btn-sm block-button">
<span id="mute-button-span" class="glyphicon glyphicon-volume-off"></span>
</button>
<button type="button" id="volume_down" class="btn navbar-btn btn-sm block-button">
<span id="voldown-button-span" class="glyphicon glyphicon-volume-down"></span>
</button>
<button type="button" id="volume_up" class="btn navbar-btn btn-sm block-button">
<span id="volup-button-span" class="glyphicon glyphicon-volume-up"></span>
</button>
{% endif %}
{% if perms.queues.can_skip %}
<button type="button" id="skip" class="btn navbar-btn btn-sm block-button">
<span id="skip-button-span" class="glyphicon glyphicon-fast-forward"></span>
</button>
{% endif %}
</div>
</li>
</ul>
<ul class="nav navbar-nav navbar-right hidden-xs">
<li>
<div class="infobar">
<p class="navbar-text start-queue hidden-sm hidden-xs"></p>
<p class="navbar-text end-queue"></p>
<div class="navbar-text">
<p class="duration-queue"></p>
<a href="#" v-if="song.can_delete" v-on:click="cancel_song(song.id)"><i
class="fa-solid fa-trash-can"></i></a>
<a href="#" v-else class="invisible"><i class="fa-solid fa-trash-can"></i></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</li>
</ul>
</div>
</nav>
<br><br>
<div class="alert-location">
</div>
<div id="request-container" class="hidden">
<table id="request-table" class="table table-striped">
<thead>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Uploader</th>
<th style="text-align: right;">Length</th>
<th>Report</th>
</tr>
<tr>
<th colspan="2"><input id="search-all" class="search-input" type="text"></th>
<th colspan="3"><input id="search-uploader" 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>
<th colspan="2"></th>
</tr>
</tfoot>
<tbody>
</tbody>
</table>
</div>
<div id="queue-container">
<table class="table table-striped">
<thead>
<tr class="table-header-style">
<td class="col-md-4">Artist</td>
<td class="col-md-4">Title</td>
<td class="col-md-2 hidden-xs">Requested By</td>
<td class="col-md-1 hidden-xs text-info" style="cursor: pointer;">
<span id="timeswitch" class="btn-link" >Plays In</span>
</td>
<td class="col-md-1 control-icons">Control</td>
</tr>
<tr class="currentsong" style="font-weight: bold">
<td class="artist"></td>
<td class="title"></td>
<td class="requested-by hidden-xs"></td>
<td colspan="2"></td>
</tr>
</thead>
<tbody class="queuebody">
</tbody>
</table>
</div>
<div class="tab-pane fade" id="request" role="tabpanel" aria-labelledby="request-tab">
<div id="request-container">
<table id="request-table" class="table table-striped">
<thead>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Uploader</th>
<th>Length</th>
<th>Report</th>
</tr>
<tr>
<th colspan="5"><input id="search-all" class="search-input" type="text"
v-model="search_input"/></th>
</tr>
</thead>
<tfoot>
<tr>
<th colspan="3" 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>
<script type="text/javascript" src="{% static 'js/js.cookie-2.1.3.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/queue.js' %}?1"></script>
<script type="text/javascript">
var csrf_token = "{{ csrf_token }}";
var canSkip = {{ perms.queues.can_skip|yesno:"1,0" }};
var canCancel = {{ perms.queues.can_cancel|yesno:"1,0" }};
var canMoveSongs = {{ perms.queues.can_move|yesno:"1,0" }};
</script>
<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="#" v-on:click="request_song(song.id);"><% song.title %></a>
</td>
<td>
<template v-if="song.user === null">
Marietje
</template>
<template v-else>
<% song.user.name %>
</template>
</td>
<td>
<% song.duration.secondsToMMSS() %>
</td>
<td>
<a href="#" v-on:click="report_song(song.id);">
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
const CAN_SKIP = {{ perms.queues.can_skip|yesno:"1,0" }};
const CAN_CANCEL = {{ perms.queues.can_cancel|yesno:"1,0" }};
const CAN_MOVE = {{ perms.queues.can_move|yesno:"1,0" }};
</script>
<script>
const queue_vue = new Vue({
el: '#queue-container',
delimiters: ['<%', '%>'],
data: {
current_song: null,
queue: [],
user_data: null,
refreshing: true,
refreshTimer: null,
clockInterval: null,
started_at: null,
playsIn: true,
},
mounted() {
this.clockInterval = setInterval(this.update_song_times, 1000);
},
unmounted() {
clearInterval(this.clockInterval);
},
created() {
fetch('/api/v1/users/me/').then(response => {
if (response.status === 200) {
return response.json();
} else {
throw response;
}
}).then(data => {
this.user_data = data;
}).then(() => {
this.refreshing = false;
this.refresh();
}).catch(() => {
tata.error('', 'User details failed to fetch, please reload this page to try again.');
}).finally(() => {
this.refreshing = false;
});
},
computed: {
infoBar() {
let infoBar = {
start_personal_queue: 0,
length_personal_queue: 0,
length_total_queue: 0,
end_personal_queue: 0,
max_length: 45,
}
for (let i = 0; i < this.queue.length; i++) {
const current_song = this.queue[i];
infoBar['length_total_queue'] = infoBar['length_total_queue'] + current_song.song.duration;
if (current_song.user !== null && current_song.user.id === this.user_data.id) {
infoBar['length_personal_queue'] = infoBar['length_personal_queue'] + current_song.song.duration;
infoBar['end_personal_queue'] = infoBar['length_total_queue'];
if (infoBar['start_personal_queue'] === 0) {
infoBar['start_personal_queue'] = infoBar['length_total_queue'] - current_song.song.duration;
}
}
}
return infoBar;
},
play_next_song_at() {
if (this.started_at !== null && this.current_song !== null) {
return this.started_at + this.current_song.song.duration;
}
return null;
}
},
methods: {
update_song_times() {
const now_in_seconds = Math.round((new Date()).getTime() / 1000);
let total_song_length = 0;
for (let i = 0; i < this.queue.length; i++) {
if (this.started_at === null) {
this.queue[i].time_until_song_seconds = null;
this.queue[i].plays_at = null;
this.queue[i].played = false;
} else {
this.queue[i].time_until_song_seconds = total_song_length;
this.queue[i].plays_at = now_in_seconds + total_song_length;
this.queue[i].played = this.queue[i].plays_at <= now_in_seconds;
if (i === 0) {
total_song_length += this.queue[i].song.duration - (now_in_seconds - this.started_at);
} else {
total_song_length += this.queue[i].song.duration;
}
}
}
},
refresh() {
if (!this.refreshing) {
this.refreshing = true;
clearTimeout(this.refreshTimer);
fetch('/api/v1/queues/current/').then(response => {
if (response.status === 200) {
return response.json();
} else {
throw response;
}
}).then(data => {
this.current_song = data.current_song;
this.started_at = Math.round((new Date(data.started_at).getTime()) / 1000);
let newQueue = data.queue;
newQueue.unshift(this.current_song);
newQueue = this.annotateQueue(newQueue);
clearInterval(this.clockInterval);
this.queue = newQueue;
this.update_song_times();
this.clockInterval = setInterval(this.update_song_times, 1000);
}).finally(() => {
this.refreshing = false;
this.refreshTimer = setTimeout(this.refresh, 10000);
});
}
},
annotateQueue(queue) {
for (let i = 0; i < queue.length; i++) {
const can_delete_previous = i === 0 || i === 1 ? false : queue[i - 1].can_delete;
const previous_requested_by_user = i === 0 || i === 1 ? false : queue[i - 1].user !== null;
const requested_by_marietje = queue[i].user === null;
const next_is_marietje = i < queue.length - 1 && queue[i].user !== null && queue[i + 1].user === null;
queue[i].can_delete = i !== 0 && (CAN_MOVE || (queue[i].user !== null && queue[i].user.id === this.user_data.id));
queue[i].can_move_up = i !== 0 && ((CAN_MOVE && previous_requested_by_user && queue[i].user !== null) ||
(CAN_MOVE && !previous_requested_by_user && queue[i].user === null) ||
(can_delete_previous && !requested_by_marietje && previous_requested_by_user));
queue[i].can_move_down = i !== 0 && ((CAN_MOVE && !next_is_marietje && i < queue.length - 1) ||
(queue[i].can_delete && requested_by_marietje && next_is_marietje && i < queue.length - 1));
queue[i].plays_at = null;
queue[i].time_until_song_seconds = null;
queue[i].played = false;
}
return queue;
},
cancel_song(id) {
fetch(
'/api/v1/queues/playlist-song/' + id + '/cancel/',
{
method: "DELETE",
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}
).finally(() => {
this.refresh();
});
},
move_down(id) {
fetch(
'/api/v1/queues/playlist-song/' + id + '/move-down/',
{
method: "PATCH",
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}
).finally(() => {
this.refresh();
});
}
}
});
</script>
<script>
const request_vue = new Vue({
el: '#request-container',
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.search();
}
},
page_size: {
handler(val, oldVal) {
if (this.page_size <= 0) {
this.page_size = 10;
}
this.page_number = 1;
this.search();
}
}
},
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() {
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.")
}
});
},
request_song(song_id) {
fetch('/api/v1/queues/current/request/', {
method: 'POST',
body: JSON.stringify({
song: song_id
}),
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}).then(response => {
if (response.status === 200) {
return response.json();
} else {
throw response;
}
}).then(() => {
tata.success('', 'Song added to the queue.');
queue_vue.refresh();
}).catch(e => {
if (e instanceof Response) {
e.json().then(data => {
tata.error('', data.errorMessage);
})
} else {
tata.error('', "An unknown exception occurred.")
}
});
},
report_song(song_id) {
let message = prompt("What is wrong with the song?");
if (message === null) {
return;
}
if (message === "") {
tata.error('', 'Please enter a message.');
}
fetch('/api/v1/songs/report-notes/', {
method: 'POST',
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
body: JSON.stringify({
song: song_id,
note: message,
}),
}).then(response => {
if (response.status === 201) {
return response.json();
} else {
throw response;
}
}).then(() => {
tata.success("", "Successfully submitted report note.")
}).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>
<script>
function volume_down() {
fetch('/api/v1/queues/current/volume-down/', {
method: "POST",
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}).then(response => {
if (response.status !== 200) {
throw response;
}
}).catch((e) => {
if (e instanceof Response) {
tata.error("", e.errorMessage);
} else {
tata.error("", "An unknown error occurred.")
}
});
}
function volume_up() {
fetch('/api/v1/queues/current/volume-up/', {
method: "POST",
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}).then(response => {
if (response.status !== 200) {
throw response;
}
}).catch((e) => {
if (e instanceof Response) {
tata.error("", e.errorMessage);
} else {
tata.error("", "An unknown error occurred.")
}
});
}
function mute() {
fetch('/api/v1/queues/current/mute/', {
method: "POST",
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}).then(response => {
if (response.status !== 200) {
throw response;
}
}).catch((e) => {
if (e instanceof Response) {
tata.error("", e.errorMessage);
} else {
tata.error("", "An unknown error occurred.")
}
});
}
function skip() {
fetch('/api/v1/queues/current/skip/', {
method: "POST",
headers: {
"X-CSRFToken": CSRF_TOKEN,
"Accept": 'application/json',
"Content-Type": 'application/json',
},
}).then(response => {
if (response.status !== 200) {
throw response;
}
}).then(() => {
queue_vue.refresh();
}).catch((e) => {
if (e instanceof Response) {
tata.error("", e.errorMessage);
} else {
tata.error("", "An unknown error occurred.")
}
});
}
</script>
{% endblock %}

View File

@ -1,2 +1,2 @@
app_name = 'queue'
app_name = "queue"
urlpatterns = []

View File

@ -1,7 +1,6 @@
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
@login_required
def index(request):
return render(request, 'queues/queue.html')
class QueueView(LoginRequiredMixin, TemplateView):
template_name = "queues/queue.html"