1 Commits

Author SHA1 Message Date
11e8ff11b8 Add logging for API endpoints 2023-11-25 07:38:28 +01:00
35 changed files with 634 additions and 597 deletions

View File

@ -1,4 +0,0 @@
marietje/db.sqlite3
marietje/static
docker-compose.yml.example
docker-compose.yml

View File

@ -12,12 +12,11 @@ black:
- python3 -m pip install --upgrade pip - python3 -m pip install --upgrade pip
- curl -sSL https://install.python-poetry.org | python3 - - curl -sSL https://install.python-poetry.org | python3 -
- export PATH="/root/.local/bin:$PATH" - export PATH="/root/.local/bin:$PATH"
- poetry install --with dev - poetry install --with dev --no-root
script: script:
- poetry run black --quiet --check marietje - poetry run black --quiet --check marietje
# TODO: Fix the deploy stage, as it has not been adapted to the new server Marietje runs on. The . disables the stage. deploy:
.deploy:
stage: deploy stage: deploy
only: ['marietje-zuid'] only: ['marietje-zuid']
before_script: before_script:

View File

@ -1,26 +0,0 @@
FROM python:3.11
MAINTAINER Tartarus Technicie
ENV PYTHONUNBUFFERED 1
ENV DJANGO_SETTINGS_MODULE marietje.settings.production
ENV PATH /root/.poetry/bin:${PATH}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
WORKDIR /marietje/src
COPY resources/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY poetry.lock pyproject.toml /marietje/src/
RUN \
mkdir --parents /marietje/src/ && \
mkdir --parents /marietje/log/ && \
mkdir --parents /marietje/static/ && \
chmod +x /usr/local/bin/entrypoint.sh && \
\
curl -sSL https://install.python-poetry.org | python3 - && \
export PATH="/root/.local/bin:$PATH" && \
poetry config --no-interaction --no-ansi virtualenvs.create false && \
poetry install --no-interaction --no-ansi --no-dev
COPY marietje /marietje/src/website/

View File

@ -1,43 +0,0 @@
services:
reverse-proxy:
container_name: 'marietje-reverse-proxy'
image: nginx:latest
restart: 'always'
depends_on:
- backend
ports:
- 80:80
volumes:
- ./data/shared/media/:/marietje/media/
- ./data/shared/static/:/marietje/static/
- ./data/reverse-proxy/conf.d/:/etc/nginx/conf.d/
- ./data/reverse-proxy/nginx.conf:/etc/nginx/nginx.conf
networks:
- marietje-network
backend:
build: "."
restart: 'always'
container_name: 'marietje-backend'
volumes:
- ./data/shared/static/:/marietje/src/website/static/
- ./data/shared/media/:/marietje/src/website/media/
- ./data/backend/log/:/marietje/log/
environment:
DJANGO_SECRET_KEY: '[Django Secret key]'
VIRTUAL_HOST: '[Marietje hostname]'
VIRTUAL_PROTO: 'uwsgi'
DJANGO_ALLOWED_HOST: 'marietje-zuid.nl'
DJANGO_MYSQL_NAME: 'marietje'
DJANGO_MYSQL_USER: 'marietje'
DJANGO_MYSQL_PASSWORD: '[Marietje zuid database password]'
DJANGO_MYSQL_HOST: 'localhost'
DJANGO_MYSQL_PORT: '3306'
DJANGO_BERTHA_HOST: 'bach.science.ru.nl'
DJANGO_BERTHA_PORT: '1234'
networks:
- marietje-network
networks:
marietje-network:
driver: bridge

View File

@ -17,7 +17,7 @@ if __name__ == "__main__":
# issue is really that Django is missing to avoid masking other # issue is really that Django is missing to avoid masking other
# exceptions on Python 2. # exceptions on Python 2.
try: try:
import django import django # noqa
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "

View File

@ -7,13 +7,13 @@ from .models import User
@admin.register(User) @admin.register(User)
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
fieldsets = ( fieldsets = (
(None, {"fields": ("username", "password", "queue")}), (None, {"fields": ("username", "password")}),
(_("Personal info"), {"fields": ("name", "email")}), (_("Personal info"), {"fields": ("name", "email")}),
(_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), (_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
(_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Activation"), {"fields": ("activation_token", "reset_token")}), (_("Activation"), {"fields": ("activation_token", "reset_token")}),
) )
list_display = ("username", "email", "name", "date_joined", "last_login", "queue", "is_staff") list_display = ("username", "email", "name", "date_joined", "last_login", "is_staff")
search_fields = ("username", "name", "email") search_fields = ("username", "name", "email")
def delete_model(self, request, user): def delete_model(self, request, user):

View File

@ -28,7 +28,6 @@ class Command(BaseCommand):
user.name = import_user["n"].strip() user.name = import_user["n"].strip()
user.email = user.username + "@science.ru.nl" user.email = user.username + "@science.ru.nl"
user.password = "md5$$" + import_user["p"] user.password = "md5$$" + import_user["p"]
user.queue = get_first_queue()
user.save() user.save()
if options["tsv_file"]: if options["tsv_file"]:
@ -45,7 +44,6 @@ class Command(BaseCommand):
user.name = import_user[2].decode("utf-8", errors="ignore").strip() user.name = import_user[2].decode("utf-8", errors="ignore").strip()
user.email = user.username + "@science.ru.nl" user.email = user.username + "@science.ru.nl"
user.password = import_user[3].decode("utf-8", errors="strict") user.password = import_user[3].decode("utf-8", errors="strict")
user.queue = get_first_queue()
user.study = import_user[5].decode("utf-8", errors="ignore").strip() user.study = import_user[5].decode("utf-8", errors="ignore").strip()
user.save() user.save()

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.6 on 2023-11-24 20:17
from django.db import migrations
def create_new_queue_mappings(apps, schema_editor):
"""Before removing the old reference to Queue from User, we should move this to the newly created model."""
User = apps.get_model("marietje", "User")
UserQueue = apps.get_model("queues", "UserQueue")
for user in User.objects.all():
if user.queue is not None:
UserQueue.objects.create(user=user, queue=user.queue)
else:
UserQueue.objects.create(user=user)
class Migration(migrations.Migration):
dependencies = [
("marietje", "0008_alter_user_id"),
("queues", "0012_userqueue_queuelogentry"),
]
operations = [
migrations.RunPython(
create_new_queue_mappings,
migrations.RunPython.noop
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.6 on 2023-11-24 20:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("marietje", "0009_auto_20231124_2117"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="queue",
),
]

View File

@ -7,9 +7,6 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from marietje.utils import get_first_queue
from queues.models import Queue
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
use_in_migrations = True use_in_migrations = True
@ -19,9 +16,8 @@ class UserManager(BaseUserManager):
raise ValueError("The given username must be set") raise ValueError("The given username must be set")
email = self.normalize_email(email) email = self.normalize_email(email)
username = self.model.normalize_username(username) username = self.model.normalize_username(username)
queue = get_first_queue()
user = self.model(username=username, email=email, queue=queue, **extra_fields) user = self.model(username=username, email=email, **extra_fields)
user.set_password(password) user.set_password(password)
user.save(using=self._db) user.save(using=self._db)
return user return user
@ -80,8 +76,6 @@ class User(AbstractBaseUser, PermissionsMixin):
objects = UserManager() objects = UserManager()
queue = models.ForeignKey(Queue, on_delete=models.SET_NULL, blank=True, null=True)
activation_token = models.TextField(_("activation token"), blank=True, null=True) activation_token = models.TextField(_("activation token"), blank=True, null=True)
reset_token = models.TextField(_("reset token"), blank=True, null=True) reset_token = models.TextField(_("reset token"), blank=True, null=True)

View File

@ -4,6 +4,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
INSTALLED_APPS = [ INSTALLED_APPS = [
'marietje',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -15,7 +16,6 @@ INSTALLED_APPS = [
'rest_framework', 'rest_framework',
'tinymce', 'tinymce',
'announcements', 'announcements',
'marietje',
'queues', 'queues',
'songs', 'songs',
'stats', 'stats',

View File

@ -1,49 +0,0 @@
import os
from .base import *
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
DEBUG = False
ALLOWED_HOSTS = [os.environ.get("DJANGO_ALLOWED_HOST")]
SESSION_COOKIE_SECURE = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get("DJANGO_MYSQL_NAME"),
'USER': os.environ.get("DJANGO_MYSQL_USER"),
'PASSWORD': os.environ.get("DJANGO_MYSQL_PASSWORD"),
'HOST': os.environ.get("DJANGO_MYSQL_HOST"),
'PORT': os.environ.get("DJANGO_MYSQL_PORT"),
'OPTIONS': {'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"},
}
}
# Logging
# https://docs.djangoproject.com/en/3.2/topics/logging/
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"file": {
"level": "INFO",
"class": "logging.FileHandler",
"filename": "/marietje/log/django.log",
},
},
"loggers": {
"": {
"handlers": ["file"],
"level": "DEBUG",
"propagate": True,
}, # noqa
}, # noqa
}
BASE_URL = 'https://marietje-zuid.science.ru.nl'
BERTHA_HOST = (os.environ.get("DJANGO_BERTHA_HOST"), os.environ.get("DJANGO_BERTHA_PORT"))

View File

@ -0,0 +1,23 @@
from .base import *
SECRET_KEY = '******'
DEBUG = False
ALLOWED_HOSTS = ['marietje-zuid.nl']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'marietje',
'USER': 'marietje',
'PASSWORD': '******',
'HOST': 'localhost',
'PORT': '3306',
'OPTIONS': {'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"},
}
}
BASE_URL = 'https://marietje-zuid.science.ru.nl'
BERTHA_HOST = ('bach.science.ru.nl', 1234)

View File

@ -31,6 +31,19 @@ a {
color: var(--text-color); color: var(--text-color);
} }
.table {
color: var(--text-color);
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: var(--background-shade);
color: var(--text-color);
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
color: var(--text-color);
}
input[type="text"], input[type="password"] { input[type="text"], input[type="password"] {
background-color: var(--background-shade-light); background-color: var(--background-shade-light);
border: 1px solid var(--background-shade); border: 1px solid var(--background-shade);
@ -53,23 +66,22 @@ button[type="button"] i {
min-width: 90px; min-width: 90px;
} }
.song-info {
position: absolute;
padding: 8px;
background: silver;
white-space: nowrap;
z-index: 1;
}
#queue-time-header {
cursor: pointer;
}
footer { footer {
text-align: center; text-align: center;
} }
.table {
color: var(--text-color);
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: var(--background-shade);
color: var(--text-color);
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
color: var(--text-color);
}
.marietjequeue { .marietjequeue {
color: #777777; color: #777777;
} }
@ -79,24 +91,7 @@ footer {
} }
.marietjequeue-pre-start td { .marietjequeue-pre-start td {
border-bottom: 3px double var(--text-color); border-bottom: 3px double #777777;
}
.marietjequeue-post-start td {
border-top: 3px double var(--text-color);
}
.ownsong {
border-left: 1px solid var(--text-color);
}
.currentsong {
border-bottom: 1px solid var(--text-color);
font-weight: bold;
}
.underline_cell {
border-bottom: 1px solid var(--text-color);
} }
.block-button { .block-button {
@ -105,10 +100,13 @@ footer {
transition: 1s transform ease-in-out; transition: 1s transform ease-in-out;
} }
.currentsong {
border-bottom: 1px solid #DDDDDD;
}
.navbar-text { .navbar-text {
color: var(--text-color); color: var(--text-color);
} }
.danger { .danger {
color: red !important; color: red !important;
} }

View File

@ -20,19 +20,17 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background-color: #181818; --background-color: #202020;
--background-shade: #282828; --background-shade: #404040;
--background-shade-light: #404040; --background-shade-light: #696969;
--card-background: #404040; --card-background: #696969;
--card-background-shade: #282828; --card-background-shade: #404040;
--card-background-contrast: #dddddd; --card-background-contrast: #ffffff;
--title-color: #000000; --title-color: #000000;
--sub-title-color: #dddddd; --sub-title-color: #dddddd;
--link-color: #007bff; --link-color: #007bff;
--text-color: #dddddd; --text-color: #ffffff;
--bs-border-color: #282828;
} }
} }

View File

@ -15,10 +15,7 @@
<link href="{% static 'fontawesomefree/css/all.min.css' %}" rel="stylesheet" type="text/css"> <link href="{% static 'fontawesomefree/css/all.min.css' %}" rel="stylesheet" type="text/css">
<!-- Vue JS --> <!-- Vue JS -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js"></script>
<script>
const { createApp } = Vue;
</script>
<!-- TaTa.js notifications --> <!-- TaTa.js notifications -->
<script src="{% static 'marietje/js/tata.js' %}"></script> <script src="{% static 'marietje/js/tata.js' %}"></script>
@ -40,7 +37,6 @@
</section> </section>
<nav class="navbar navbar-expand-lg sticky-top navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg sticky-top navbar-dark bg-primary">
<div class="container"> <div class="container">
<a class="navbar-brand d-block d-lg-none" href="{% url "index" %}">Marietje 4.1</a>
<button class="navbar-toggler ms-auto" type="button" data-bs-toggle="offcanvas" <button class="navbar-toggler ms-auto" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasNavbar" aria-controls="offcanvasNavbar"> data-bs-target="#offcanvasNavbar" aria-controls="offcanvasNavbar">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>

View File

@ -1,82 +0,0 @@
import binascii
import socket
import struct
from django.conf import settings
from django.http import StreamingHttpResponse
from queues.models import Queue, Playlist
def song_to_dict(song, include_hash=False, include_user=False, include_replaygain=False, **options):
data = {
"id": song.id,
"artist": song.artist,
"title": song.title,
"duration": song.duration,
}
if include_hash:
data["hash"] = song.hash
if include_user is not None and song.user is not None and song.user.name:
data["uploader_name"] = song.user.name
if include_replaygain:
data["rg_gain"] = song.rg_gain
data["rg_peak"] = song.rg_peak
return data
def playlist_song_to_dict(playlist_song, **options):
user = options.get("user")
return {
"id": playlist_song.id,
"requested_by": "Marietje" if playlist_song.user is None else playlist_song.user.name,
"song": song_to_dict(playlist_song.song, **options),
"can_move_down": playlist_song.user is not None and playlist_song.user == user,
}
# Send a file to bertha file storage.
def send_to_bertha(file):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(settings.BERTHA_HOST)
sock.sendall(struct.pack("<BQ", 4, file.size))
for chunk in file.chunks():
sock.sendall(chunk)
sock.shutdown(socket.SHUT_WR)
song_hash = binascii.hexlify(sock.recv(64))
sock.close()
return song_hash
def get_first_queue():
queue = Queue.objects.first()
if queue is None:
playlist = Playlist()
playlist.save()
random_playlist = Playlist()
random_playlist.save()
queue = Queue(name="Queue", playlist=playlist, random_playlist=random_playlist)
queue.save()
return queue
def bertha_stream(song_hash):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(settings.BERTHA_HOST)
sock.sendall(struct.pack("<B", 2) + binascii.unhexlify(song_hash))
data = sock.recv(4096)
while data:
yield data
data = sock.recv(4096)
sock.close()
def get_from_bertha(song_hash):
response = StreamingHttpResponse(bertha_stream(song_hash))
response["Content-Disposition"] = 'attachment; filename="{}"'.format(song_hash)
return response

View File

@ -0,0 +1,29 @@
def song_to_dict(song, include_hash=False, include_user=False, include_replaygain=False, **options):
data = {
"id": song.id,
"artist": song.artist,
"title": song.title,
"duration": song.duration,
}
if include_hash:
data["hash"] = song.hash
if include_user is not None and song.user is not None and song.user.name:
data["uploader_name"] = song.user.name
if include_replaygain:
data["rg_gain"] = song.rg_gain
data["rg_peak"] = song.rg_peak
return data
def playlist_song_to_dict(playlist_song, **options):
user = options.get("user")
return {
"id": playlist_song.id,
"requested_by": "Marietje" if playlist_song.user is None else playlist_song.user.name,
"song": song_to_dict(playlist_song.song, **options),
"can_move_down": playlist_song.user is not None and playlist_song.user == user,
}

View File

@ -4,11 +4,11 @@ from django.http import JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from marietje.utils import playlist_song_to_dict
from queues.models import Queue from queues.models import Queue
from songs.models import Song from songs.models import Song
from .decorators import token_required from .decorators import token_required
from .services import playlist_song_to_dict
@csrf_exempt @csrf_exempt

View File

@ -1,9 +1,18 @@
from django.contrib import admin from typing import Optional
from .models import Queue, Playlist, PlaylistSong, QueueCommand
from django.contrib import admin
from django.contrib.auth import get_user_model
from .models import Queue, Playlist, PlaylistSong, QueueCommand, QueueLogEntry, UserQueue
from marietje.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from .services import get_queue_for_user
admin.site.register(Playlist) admin.site.register(Playlist)
User = get_user_model()
@admin.register(Queue) @admin.register(Queue)
class OrderAdmin(admin.ModelAdmin): class OrderAdmin(admin.ModelAdmin):
@ -21,3 +30,61 @@ class PlaylistSongAdmin(admin.ModelAdmin):
admin.site.register(QueueCommand) admin.site.register(QueueCommand)
@admin.register(QueueLogEntry)
class QueueLogEntryAdmin(admin.ModelAdmin):
"""Admin for log entries."""
list_display = [
"timestamp",
"queue",
"action",
"user",
"description",
]
list_filter = [
"queue",
"action",
("timestamp", admin.DateFieldListFilter),
]
def has_delete_permission(self, request, obj=None):
"""Disable delete permission."""
return False
def has_add_permission(self, request):
"""Disable add permission."""
return False
def has_change_permission(self, request, obj=None):
"""Disable change permission."""
return False
class UserQueueInline(admin.StackedInline):
model = UserQueue
fields = ("queue",)
class UserAdmin(BaseUserAdmin):
fieldsets = (
(None, {"fields": ("username", "password")}),
(_("Personal info"), {"fields": ("name", "email")}),
(_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Activation"), {"fields": ("activation_token", "reset_token")}),
)
list_display = ("username", "email", "name", "date_joined", "last_login", "queue__queue", "is_staff")
inlines = (UserQueueInline,)
def queue__queue(self, obj: User) -> Optional[Queue]:
"""Retrieve the Queue for a User."""
return get_queue_for_user(obj)
queue__queue.short_description = "queue"
admin.site.unregister(User)
admin.site.register(User, UserAdmin)

View File

@ -1,3 +1,4 @@
from django.db.models import Q
from rest_framework.generics import ListAPIView, RetrieveAPIView, get_object_or_404, CreateAPIView, DestroyAPIView from rest_framework.generics import ListAPIView, RetrieveAPIView, get_object_or_404, CreateAPIView, DestroyAPIView
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
@ -8,7 +9,7 @@ from django.http import Http404
from queues.api.v1.serializers import PlaylistSerializer, QueueSerializer, PlaylistSongSerializer from queues.api.v1.serializers import PlaylistSerializer, QueueSerializer, PlaylistSongSerializer
from queues.exceptions import RequestException from queues.exceptions import RequestException
from queues.models import Playlist, PlaylistSong, QueueCommand from queues.models import Playlist, PlaylistSong, QueueCommand, Queue
from queues.services import get_user_or_default_queue from queues.services import get_user_or_default_queue
from songs.counters import request_counter from songs.counters import request_counter
from songs.models import Song from songs.models import Song
@ -80,7 +81,7 @@ class QueueSkipAPIView(APIView):
if queue is None: if queue is None:
return Response(status=404) return Response(status=404)
playlist_song = request.user.queue.current_song() playlist_song = queue.current_song()
if ( if (
request.user is not None request.user is not None
and playlist_song.user != request.user and playlist_song.user != request.user
@ -90,6 +91,7 @@ class QueueSkipAPIView(APIView):
playlist_song.state = 2 playlist_song.state = 2
playlist_song.save() playlist_song.save()
queue.log_action(request.user, "next", "Skipped to next song.")
return Response(status=200, data=QueueSerializer(queue).data) return Response(status=200, data=QueueSerializer(queue).data)
@ -111,7 +113,18 @@ class PlaylistSongMoveDownAPIView(APIView):
and not request.user.has_perm("queues.can_move") and not request.user.has_perm("queues.can_move")
): ):
return Response(status=403) return Response(status=403)
playlist_song.move_down() playlist_song.move_down()
for queue in Queue.objects.filter(
Q(playlist=playlist_song.playlist) | Q(random_playlist=playlist_song.playlist)
):
queue.log_action(
request.user,
"down",
'Moved song "{}" of playlist "{}" down.'.format(playlist_song.song, playlist_song.playlist),
)
return Response(status=200, data=self.serializer_class(playlist_song).data) return Response(status=200, data=self.serializer_class(playlist_song).data)
@ -131,7 +144,18 @@ class PlaylistSongCancelAPIView(DestroyAPIView):
and not request.user.has_perm("queues.can_cancel") and not request.user.has_perm("queues.can_cancel")
): ):
return Response(status=403) return Response(status=403)
playlist_song.delete() playlist_song.delete()
for queue in Queue.objects.filter(
Q(playlist=playlist_song.playlist) | Q(random_playlist=playlist_song.playlist)
):
queue.log_action(
request.user,
"cancel",
'Cancelled song "{}" of playlist "{}".'.format(playlist_song.song, playlist_song.playlist),
)
return Response(status=200, data=self.serializer_class(playlist_song).data) return Response(status=200, data=self.serializer_class(playlist_song).data)
@ -165,6 +189,8 @@ class QueueRequestAPIView(CreateAPIView):
except RequestException as e: except RequestException as e:
return Response(data={"success": False, "errorMessage": str(e)}) return Response(data={"success": False, "errorMessage": str(e)})
queue.log_action(request.user, "request_song", "Requested song {}.".format(song))
request_counter.labels(queue=queue.name).inc() request_counter.labels(queue=queue.name).inc()
return Response(status=200, data=self.serializer_class(playlist_song).data) return Response(status=200, data=self.serializer_class(playlist_song).data)
@ -196,7 +222,11 @@ class QueueVolumeDownAPIView(APIView):
return Response(status=404) return Response(status=404)
if request.user is not None and not request.user.has_perm("queues.can_control_volume"): if request.user is not None and not request.user.has_perm("queues.can_control_volume"):
return Response(status=403) return Response(status=403)
QueueCommand.objects.create(queue=queue, command="volume_down") QueueCommand.objects.create(queue=queue, command="volume_down")
queue.log_action(request.user, "volume_down", "Reduced the volume of {}.".format(queue))
return Response(status=200, data=self.serializer_class(queue).data) return Response(status=200, data=self.serializer_class(queue).data)
@ -227,7 +257,11 @@ class QueueVolumeUpAPIView(APIView):
return Response(status=404) return Response(status=404)
if request.user is not None and not request.user.has_perm("queues.can_control_volume"): if request.user is not None and not request.user.has_perm("queues.can_control_volume"):
return Response(status=403) return Response(status=403)
QueueCommand.objects.create(queue=queue, command="volume_up") QueueCommand.objects.create(queue=queue, command="volume_up")
queue.log_action(request.user, "volume_up", "Increased the volume of {}.".format(queue))
return Response(status=200, data=self.serializer_class(queue).data) return Response(status=200, data=self.serializer_class(queue).data)
@ -258,5 +292,9 @@ class QueueMuteAPIView(APIView):
return Response(status=404) return Response(status=404)
if request.user is not None and not request.user.has_perm("queues.can_control_volume"): if request.user is not None and not request.user.has_perm("queues.can_control_volume"):
return Response(status=403) return Response(status=403)
QueueCommand.objects.create(queue=queue, command="mute") QueueCommand.objects.create(queue=queue, command="mute")
queue.log_action(request.user, "mute", "Muted the volume of {}.".format(queue))
return Response(status=200, data=self.serializer_class(queue).data) return Response(status=200, data=self.serializer_class(queue).data)

View File

@ -0,0 +1,64 @@
# Generated by Django 4.2.6 on 2023-11-24 20:17
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("queues", "0011_alter_playlistsong_playlist"),
]
operations = [
migrations.CreateModel(
name="UserQueue",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"queue",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="users",
to="queues.queue",
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="queue_new",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="QueueLogEntry",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("action", models.CharField(max_length=255)),
("timestamp", models.DateTimeField(auto_now_add=True)),
("description", models.CharField(max_length=255)),
(
"queue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="logs", to="queues.queue"
),
),
(
"user",
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "player log entry",
"verbose_name_plural": "player log entries",
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.6 on 2023-11-24 20:19
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("queues", "0012_userqueue_queuelogentry"),
]
operations = [
migrations.AlterField(
model_name="userqueue",
name="user",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, related_name="queue", to=settings.AUTH_USER_MODEL
),
),
]

View File

@ -1,3 +1,4 @@
from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.conf import settings from django.conf import settings
@ -6,6 +7,8 @@ from django.utils import timezone
from queues.exceptions import RequestException from queues.exceptions import RequestException
from songs.models import Song from songs.models import Song
User = get_user_model()
class Playlist(models.Model): class Playlist(models.Model):
def __str__(self): def __str__(self):
@ -143,10 +146,41 @@ class Queue(models.Model):
playlist_song.save() playlist_song.save()
song_count += 1 song_count += 1
def log_action(self, user: User, action: str, description: str) -> "QueueLogEntry":
"""
Log a queue action.
:param user: The user performing the action.
:param action: An identifier of the action performed.
:param description: An optional description for the action.
:return: The created QueueLogEntry object.
"""
return QueueLogEntry.objects.create(
queue=self,
user=user,
action=action,
description=description,
)
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
class UserQueue(models.Model):
"""
UserQueue model.
This model connects a user to its queue.
"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="queue")
queue = models.ForeignKey(Queue, on_delete=models.SET_NULL, null=True, blank=True, related_name="users")
def __str__(self):
"""Convert this object to string."""
return "Queue for user {}".format(self.user)
class QueueCommand(models.Model): class QueueCommand(models.Model):
queue = models.ForeignKey( queue = models.ForeignKey(
Queue, Queue,
@ -157,3 +191,20 @@ class QueueCommand(models.Model):
def __str__(self): def __str__(self):
return str(self.command) return str(self.command)
class QueueLogEntry(models.Model):
"""Model for logging queue events."""
queue = models.ForeignKey(Queue, on_delete=models.CASCADE, related_name="logs")
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
action = models.CharField(max_length=255)
timestamp = models.DateTimeField(auto_now_add=True)
description = models.CharField(max_length=255)
def __str__(self):
return f"{self.queue} {self.action} by {self.user} at {self.timestamp}"
class Meta:
verbose_name = "player log entry"
verbose_name_plural = "player log entries"

View File

@ -1,15 +1,44 @@
from queues.models import Queue from typing import Optional
from django.contrib.auth import get_user_model
from queues.models import Queue, Playlist
from django.conf import settings from django.conf import settings
def get_user_or_default_queue(request): User = get_user_model()
"""Get the user or default queue."""
def get_user_or_default_queue(request) -> Queue:
"""Get the user or default queue from a request."""
if request.user is None: if request.user is None:
return get_default_queue() return get_default_queue()
else: else:
return request.user.queue return get_queue_for_user(request.user)
def get_default_queue(): def get_queue_for_user(user: User) -> Optional[Queue]:
"""Get the queue for a User."""
if user.queue is None:
return None
else:
return user.queue.queue
def get_default_queue() -> Queue:
"""Get the default queue.""" """Get the default queue."""
return Queue.objects.get(pk=settings.DEFAULT_QUEUE) try:
return Queue.objects.get(pk=settings.DEFAULT_QUEUE)
except Queue.DoesNotExist:
return get_first_queue()
def get_first_queue() -> Queue:
"""Get the first Queue object or create one."""
queue = Queue.objects.first()
if queue is not None:
return queue
playlist = Playlist.objects.create()
random_playlist = Playlist.objects.create()
return Queue.objects.create(name="Queue", playlist=playlist, random_playlist=random_playlist)

View File

@ -0,0 +1,18 @@
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from queues.models import UserQueue
from queues.services import get_default_queue
User = get_user_model()
@receiver(post_save, sender=User)
def create_default_queue(sender, instance, created, **kwargs):
"""Create a UserQueue object when a User gets created."""
if created:
user_queue, user_queue_created = UserQueue.objects.get_or_create(user=instance)
if user_queue_created:
user_queue.queue = get_default_queue()
user_queue.save()

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<nav class="navbar navbar-expand navbar-default navbar-light border-bottom"> <nav class="navbar navbar-expand navbar-default navbar-light border-bottom">
<div class="container-lg"> <div class="container">
<ul class="nav nav-pills" role="tablist"> <ul class="nav nav-pills" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="queue-tab" data-bs-toggle="tab" data-bs-target="#queue" <button class="nav-link active" id="queue-tab" data-bs-toggle="tab" data-bs-target="#queue"
@ -13,7 +13,7 @@
</button> </button>
</li> </li>
<li class="nav-item me-3" role="presentation"> <li class="nav-item me-3" role="presentation">
<button onclick="request_vue.select_textinput()" class="nav-link" id="request-tab" data-bs-toggle="tab" data-bs-target="#request" <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 type="button" role="tab" aria-controls="request" aria-selected="false">Request
</button> </button>
</li> </li>
@ -39,32 +39,30 @@
</li> </li>
</ul> </ul>
<ul id="personal-queue-container" class="navbar-nav navbar-right hidden-xs"> <ul v-if="'start_personal_queue' in infobar && infobar.start_personal_queue !== null" id="personal-queue-container" class="navbar-nav navbar-right hidden-xs">
<template v-if="infobar !== null && 'start_personal_queue' in infobar && infobar.start_personal_queue !== null"> <li v-if="infobar.start_personal_queue != 0" class="nav-item me-3">
<li v-if="infobar.start_personal_queue !== 0" class="nav-item me-3"> <p v-if="infobar.plays_in" class="navbar-text mb-0 start-queue hidden-sm hidden-xs">
<p v-if="infobar.plays_in" class="navbar-text mb-0 start-queue hidden-sm hidden-xs"> First song starts in <% infobar.start_personal_queue.secondsToMMSS() %>
First song starts in ${ infobar.start_personal_queue.secondsToMMSS() }$ </p>
</p> <p v-else class="navbar-text mb-0 start-queue hidden-sm hidden-xs">
<p v-else class="navbar-text mb-0 start-queue hidden-sm hidden-xs"> First song starts at <% (infobar.now_in_seconds + infobar.start_personal_queue).timestampToHHMMSS() %>
First song starts at ${ (infobar.now_in_seconds + infobar.start_personal_queue).timestampToHHMMSS() }$ </p>
</p> </li>
</li> <li class="nav-item me-3">
<li class="nav-item me-3"> <p v-if="infobar.plays_in" class="navbar-text mb-0 start-queue hidden-sm hidden-xs">
<p v-if="infobar.plays_in" class="navbar-text mb-0 start-queue hidden-sm hidden-xs"> Last song ends in <% infobar.end_personal_queue.secondsToMMSS() %>
Last song ends in ${ infobar.end_personal_queue.secondsToMMSS() }$ </p>
</p> <p v-else class="navbar-text mb-0 start-queue hidden-sm hidden-xs">
<p v-else class="navbar-text mb-0 start-queue hidden-sm hidden-xs"> Last song ends at <% (infobar.now_in_seconds + infobar.end_personal_queue).timestampToHHMMSS() %>
Last song ends at ${ (infobar.now_in_seconds + infobar.end_personal_queue).timestampToHHMMSS() }$ </p>
</p> </li>
</li> <li class="nav-item">
<li class="nav-item"> <p class="navbar-text mb-0 duration-queue" v-bind:class="{danger: infobar.length_personal_queue > infobar.max_length * 60}">(<% infobar.length_personal_queue.secondsToMMSS() %>)</p>
<p class="navbar-text mb-0 duration-queue" v-bind:class="{danger: infobar.length_personal_queue > infobar.max_length * 60}">(${ infobar.length_personal_queue.secondsToMMSS() }$)</p> </li>
</li>
</template>
</ul> </ul>
</div> </div>
</nav> </nav>
<div class="container-lg"> <div class="container">
<br><br> <br><br>
<div class="alert-location"> <div class="alert-location">
</div> </div>
@ -73,85 +71,53 @@
<div id="queue-container"> <div id="queue-container">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr class="table-header-style underline_cell"> <tr class="table-header-style">
<td class="col-md-4">Artist</td> <td class="col-md-4">Artist</td>
<td class="col-md-4">Title</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-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;"> <td class="col-md-1 text-info d-sm-table-cell d-none" style="cursor: pointer;">
<span v-if="playsIn" class="btn btn-link p-0" v-on:click="playsIn = false">Plays In</span> <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> <span v-else class="btn btn-link p-0" v-on:click="playsIn = true">Plays At</span>
</td> </td>
<td class="col-md-1"> <td class="col-md-1 control-icons">Control</td>
<span class="control-icons">Control</span>
<span v-if="playsIn" class="btn btn-link p-0 d-sm-none" v-on:click="toggle_details(song)">(Plays in)</span>
<span v-else class="btn btn-link p-0 d-sm-none" v-on:click="toggle_details(song)">(Plays At)</span>
</td>
</tr> </tr>
</thead> </thead>
<tbody class="queuebody"> <tbody class="queuebody">
<template v-for="(song, index) in queue"> <template v-for="(song, index) in queue">
<tr :class="{ marietjequeue: (song.user === null), <tr :class="{ marietjequeue: (song.user === null), currentsong: (index === 0), 'fw-bold': (index === 0) }">
underline_cell: (index === queue[-1]), <td class="artist"><% song.song.artist %></td>
currentsong: (index === 0), <td class="title"><% song.song.title %></td>
ownsong: (this.user_data.id === song.user?.id && index !== 0),
}"
v-on:click="toggle_details(song)">
<td>
<span class="artist">${ song.song.artist }$</span>
<span v-if="show_details(song)" class="requested-by d-sm-none d-block small mt-3 fw-normal">
Requested by:<br>
<template v-if="song.user === null">
Marietje
</template>
<template v-else>
${ song.user.name }$
</template>
</span>
</td>
<td>
<span class="title">${ song.song.title }$</span>
<span v-if="show_details(song) && song.time_until_song_seconds > 0" class="plays-at d-sm-none d-block small mt-3 fw-normal" style="text-align: right">
<span v-if="playsIn">Plays In:</span>
<span v-else>Plays At:</span>
<br>
<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>
</span>
</td>
<td class="d-sm-table-cell d-none requested-by"> <td class="d-sm-table-cell d-none requested-by">
<template v-if="song.user === null"> <template v-if="song.user === null">
Marietje Marietje
</template> </template>
<template v-else> <template v-else>
${ song.user.name }$ <% song.user.name %>
</template> </template>
</td> </td>
<td class="d-sm-table-cell d-none plays-at" style="text-align: right"> <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"> <template v-if="song.time_until_song_seconds !== null && song.time_until_song_seconds > 0 && playsIn === true">
${ song.time_until_song_seconds.secondsToMMSS() }$ <% song.time_until_song_seconds.secondsToMMSS() %>
</template> </template>
<template v-else-if="playsIn === false && song.plays_at !== null && song.played === false"> <template v-else-if="playsIn === false && song.plays_at !== null && song.played === false">
${ song.plays_at.timestampToHHMMSS() }$ <% song.plays_at.timestampToHHMMSS() %>
</template> </template>
</td> </td>
<td> <td>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<button v-if="song.can_move_up" v-on:click="move_down(queue[index-1].id)" <button v-if="song.can_move_up" v-on:click="move_down(queue[index-1].id)" class="btn btn-link"><i
class="btn btn-link p-1 p-md-2"><i class="fa-solid fa-arrow-up"></i></button> class="fa-solid fa-arrow-up"></i></button>
<button v-else class="btn btn-link invisible p-1 p-md-2"><i class="fa-solid fa-arrow-up"></i></button> <button v-else class="btn btn-link invisible"><i class="fa-solid fa-arrow-up"></i></button>
<button v-if="song.can_move_down" v-on:click="move_down(song.id)" <button v-if="song.can_move_down" v-on:click="move_down(song.id)" class="btn btn-link"><i
class="btn btn-link p-1 p-md-2"><i class="fa-solid fa-arrow-down"></i></button> class="fa-solid fa-arrow-down"></i></button>
<button v-else class="btn btn-link invisible p-1 p-md-2"><i class="fa-solid fa-arrow-down"></i></button> <button v-else class="btn btn-link invisible"><i class="fa-solid fa-arrow-down"></i></button>
</div>
<button v-if="song.can_delete" v-on:click="cancel_song(song.id)" <div class="d-flex flex-row">
class="btn btn-link p-1 p-md-2"><i class="fa-solid fa-trash-can"></i></button> <button v-if="song.can_delete" v-on:click="cancel_song(song.id)" class="btn btn-link"><i
<button v-else class="btn btn-link invisible p-1 p-md-2"><i class="fa-solid fa-trash-can"></i></button> class="fa-solid fa-trash-can"></i></button>
<button v-else class="btn btn-link invisible"><i class="fa-solid fa-trash-can"></i></button>
</div> </div>
</div> </div>
</td> </td>
@ -162,7 +128,7 @@
</div> </div>
</div> </div>
<div class="tab-pane fade" id="request" role="tabpanel" aria-labelledby="request-tab"> <div class="tab-pane fade" id="request" role="tabpanel" aria-labelledby="request-tab">
<div id="request-container" class="table-responsive"> <div id="request-container">
<table id="request-table" class="table table-striped"> <table id="request-table" class="table table-striped">
<thead> <thead>
<tr> <tr>
@ -173,7 +139,7 @@
<th>Report</th> <th>Report</th>
</tr> </tr>
<tr> <tr>
<th colspan="5"><input id="search-all" class="search-input" type="text" ref="search_textinput" <th colspan="5"><input id="search-all" class="search-input" type="text"
v-model="search_input"/></th> v-model="search_input"/></th>
</tr> </tr>
</thead> </thead>
@ -210,7 +176,7 @@
</select> </select>
<select class="pagenum input-mini" title="Select page number" v-model="page_number"> <select class="pagenum input-mini" title="Select page number" v-model="page_number">
<template v-for="(i, index) in number_of_pages"> <template v-for="(i, index) in number_of_pages">
<option :value="i">${ i }$</option> <option :value="i"><% i %></option>
</template> </template>
</select> </select>
</th> </th>
@ -221,21 +187,21 @@
<template v-for="(song, index) in songs"> <template v-for="(song, index) in songs">
<tr> <tr>
<td> <td>
${ song.artist }$ <% song.artist %>
</td> </td>
<td> <td>
<button v-on:click="request_song(song.id);" class="btn btn-link p-0 text-decoration-none">${ song.title }$</button> <button v-on:click="request_song(song.id);" class="btn btn-link p-0 text-decoration-none"><% song.title %></button>
</td> </td>
<td> <td>
<template v-if="song.user === null"> <template v-if="song.user === null">
Marietje Marietje
</template> </template>
<template v-else> <template v-else>
${ song.user.name }$ <% song.user.name %>
</template> </template>
</td> </td>
<td> <td>
${ song.duration.secondsToMMSS() }$ <% song.duration.secondsToMMSS() %>
</td> </td>
<td> <td>
<button v-on:click="report_song(song.id);" class="btn btn-link p-0 text-decoration-none"> <button v-on:click="report_song(song.id);" class="btn btn-link p-0 text-decoration-none">
@ -259,42 +225,21 @@
const CAN_MOVE = {{ perms.queues.can_move|yesno:"1,0" }}; const CAN_MOVE = {{ perms.queues.can_move|yesno:"1,0" }};
</script> </script>
<script> <script>
const personal_queue_vue = createApp({ const queue_vue = new Vue({
delimiters: ['${', '}$'], el: '#queue-container',
data() { delimiters: ['<%', '%>'],
return { data: {
infobar: null, current_song: null,
} queue: [],
}, user_data: null,
}).mount('#personal-queue-container'); refreshing: true,
const queue_vue = createApp({ refreshTimer: null,
delimiters: ['${', '}$'], clockInterval: null,
data() { started_at: null,
return { playsIn: true,
current_song: null,
queue: [],
user_data: null,
refreshing: true,
refreshTimer: null,
clockInterval: null,
started_at: null,
playsIn: true,
songs_show_details_on_mobile: [],
}
},
watch: {
playsIn: {
handler(val, oldVal) {
this.update_infobar();
setCookie("PLAYS_IN", this.playsIn, 14);
}
},
}, },
mounted() { mounted() {
this.clockInterval = setInterval(this.update_song_times, 1000); this.clockInterval = setInterval(this.update_song_times, 1000);
const stored_playsIn = getCookie("PLAYS_IN");
this.playsIn = (stored_playsIn !== "false");
}, },
unmounted() { unmounted() {
clearInterval(this.clockInterval); clearInterval(this.clockInterval);
@ -357,14 +302,16 @@
plays_in: this.playsIn, plays_in: this.playsIn,
now_in_seconds: 0, now_in_seconds: 0,
} }
infoBar.now_in_seconds = Math.round((new Date()).getTime() / 1000); const now_in_seconds = Math.round((new Date()).getTime() / 1000);
infoBar.now_in_seconds = now_in_seconds;
let current_song_played = now_in_seconds - this.queue[0].started_at;
// If the current song is the current user's, their queue has started. // If the current song is the current user's, their queue has started.
if (this.queue[0].user.id === this.user_data.id) { if (this.queue[0].user.id == this.user_data.id) {
infoBar.start_personal_queue = 0; infoBar.start_personal_queue = 0;
} }
for (let i = 0; i < this.queue.length; i++) { for (let i = 0; i < this.queue.length; i++) {
const current_song = this.queue[i]; const current_song = this.queue[i];
if (i === 0) { if (i == 0) {
const current_song_remaining_seconds = current_song.song.duration - this.queue[1].time_until_song_seconds; const current_song_remaining_seconds = current_song.song.duration - this.queue[1].time_until_song_seconds;
infoBar['length_personal_queue'] -= current_song_remaining_seconds; infoBar['length_personal_queue'] -= current_song_remaining_seconds;
infoBar['length_total_queue'] -= current_song_remaining_seconds; infoBar['length_total_queue'] -= current_song_remaining_seconds;
@ -374,11 +321,11 @@
infoBar['length_personal_queue'] += current_song.song.duration; infoBar['length_personal_queue'] += current_song.song.duration;
infoBar['end_personal_queue'] = infoBar['length_total_queue']; infoBar['end_personal_queue'] = infoBar['length_total_queue'];
if (infoBar['start_personal_queue'] === null) { if (infoBar['start_personal_queue'] === null) {
infoBar['start_personal_queue'] = infoBar['length_total_queue'] - current_song.song.duration - this.queue[1].time_until_song_seconds; infoBar['start_personal_queue'] = infoBar['length_total_queue'] - current_song.song.duration - this.queue[1].time_until_song_seconds
} }
} }
} }
personal_queue_vue.infobar = infoBar; this.$emit("infobar", infoBar);
}, },
refresh() { refresh() {
if (!this.refreshing) { if (!this.refreshing) {
@ -463,34 +410,30 @@
this.refresh(); this.refresh();
}); });
}, },
show_details(song) {
return this.songs_show_details_on_mobile.includes(song.id);
},
toggle_details(song) {
if (!this.show_details(song)) {
this.songs_show_details_on_mobile.push(song.id);
} else {
// Deze filter is gehaat door Kees, gemaakt door Olaf. Bedankt, Olaf. Duurde wel even.
this.songs_show_details_on_mobile = this.songs_show_details_on_mobile.filter(
value => value !== song.id
);
}
},
} }
}).mount("#queue-container"); });
const personal_queue_vue = new Vue({
el: '#personal-queue-container',
delimiters: ['<%', '%>'],
data: {
infobar: [],
},
mounted() {
queue_vue.$on("infobar", infoBar => this.infobar = infoBar);
}
});
</script> </script>
<script> <script>
const request_vue = createApp({ const request_vue = new Vue({
delimiters: ['${', '}$'], el: '#request-container',
data() { delimiters: ['<%', '%>'],
return { data: {
songs: [], songs: [],
total_songs: 0, total_songs: 0,
search_input: "", search_input: "",
typing_timer: null, typing_timer: null,
page_size: 10, page_size: 10,
page_number: 1, page_number: 1,
}
}, },
watch: { watch: {
search_input: { search_input: {
@ -582,7 +525,6 @@
} }
}); });
}, },
request_song(song_id) { request_song(song_id) {
fetch('/api/v1/queues/current/request/', { fetch('/api/v1/queues/current/request/', {
method: 'POST', method: 'POST',
@ -595,8 +537,7 @@
"Content-Type": 'application/json', "Content-Type": 'application/json',
}, },
}).then(response => { }).then(response => {
// TODO: Communicate failure through HTTP error codes (403) instead of checking response.success. if (response.status === 200) {
if (response.status === 200 && response.success) {
return response.json(); return response.json();
} else { } else {
throw response; throw response;
@ -614,7 +555,6 @@
} }
}); });
}, },
report_song(song_id) { report_song(song_id) {
let message = prompt("What is wrong with the song?"); let message = prompt("What is wrong with the song?");
if (message === null) { if (message === null) {
@ -652,16 +592,11 @@
} }
}); });
}, },
update_page(page_number) { update_page(page_number) {
this.page_number = page_number; this.page_number = page_number;
}, }
select_textinput() {
this.$refs.search_textinput.select();
},
} }
}).mount('#request-container'); });
</script> </script>
<script> <script>
function volume_down() { function volume_down() {

View File

@ -86,7 +86,7 @@ class SongUploadAPIView(APIView):
song = upload_file(file, artist, title, request.user) song = upload_file(file, artist, title, request.user)
upload_counter.inc() upload_counter.inc()
return Response(status=200, data=self.serializer_class(song).data) return Response(status=200, data=self.serializer_class(song).data)
except (UploadException, ConnectionRefusedError): except UploadException:
return Response( return Response(
status=500, status=500,
data={ data={

View File

@ -1,4 +1,9 @@
from marietje.utils import send_to_bertha import binascii
import socket
import struct
from django.conf import settings
from queues.models import PlaylistSong from queues.models import PlaylistSong
from songs.models import Song from songs.models import Song
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
@ -11,6 +16,20 @@ class UploadException(Exception):
pass pass
def send_to_bertha(file):
"""Send a file to Berthad file storage."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(settings.BERTHA_HOST)
sock.sendall(struct.pack("<BQ", 4, file.size))
for chunk in file.chunks():
sock.sendall(chunk)
sock.shutdown(socket.SHUT_WR)
song_hash = binascii.hexlify(sock.recv(64))
sock.close()
return song_hash
def is_regular_queue(ps): def is_regular_queue(ps):
if not ps.played_at: if not ps.played_at:
# Request is from the old times, assume good # Request is from the old times, assume good

View File

@ -4,7 +4,7 @@
{% block title %}Manage{% endblock %} {% block title %}Manage{% endblock %}
{% block content %} {% block content %}
<div class="container-lg"> <div class="container">
<div class="table-responsive mt-5"> <div class="table-responsive mt-5">
<table id="request-table" class="table table-striped"> <table id="request-table" class="table table-striped">
<thead> <thead>
@ -41,7 +41,7 @@
</select> </select>
<select class="pagenum input-mini" title="Select page number" v-model="page_number"> <select class="pagenum input-mini" title="Select page number" v-model="page_number">
<template v-for="(i, index) in number_of_pages"> <template v-for="(i, index) in number_of_pages">
<option :value="i">${ i }$</option> <option :value="i"><% i %></option>
</template> </template>
</select> </select>
</th> </th>
@ -52,10 +52,10 @@
<template v-for="(song, index) in songs"> <template v-for="(song, index) in songs">
<tr> <tr>
<td> <td>
${ song.artist }$ <% song.artist %>
</td> </td>
<td> <td>
<a :href="'/songs/edit/' + song.id + '/'" v-on:click="request_song(song.id);">${ song.title }$</a> <a :href="'/songs/edit/' + song.id + '/'" v-on:click="request_song(song.id);"><% song.title %></a>
</td> </td>
</tr> </tr>
</template> </template>
@ -64,18 +64,17 @@
</div> </div>
</div> </div>
<script> <script>
let manage_vue = createApp({ let manage_vue = new Vue({
delimiters: ['${', '}$'], el: '#request-table',
data() { delimiters: ['<%', '%>'],
return { data: {
songs: [], songs: [],
total_songs: 0, total_songs: 0,
search_input: "", search_input: "",
typing_timer: null, typing_timer: null,
page_size: 10, page_size: 10,
page_number: 1, page_number: 1,
user_data: null, user_data: null,
}
}, },
watch: { watch: {
search_input: { search_input: {
@ -168,6 +167,6 @@
this.page_number = page_number; this.page_number = page_number;
} }
} }
}).mount('#request-table'); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -16,12 +16,9 @@
{% csrf_token %} {% csrf_token %}
<div class="fileupload fileupload-new" data-provides="fileupload"> <div class="fileupload fileupload-new" data-provides="fileupload">
<span class="btn btn-primary btn-file"> <span class="btn btn-primary btn-file">
<span v-if="fileObjects.length === 0 && !files_loading"> <span v-if="fileObjects.length === 0">
Select files Select files
</span> </span>
<span v-else-if="files_loading">
Loading new files...
</span>
<span v-else> <span v-else>
Change Change
</span> </span>
@ -32,35 +29,26 @@
<div class="songs"> <div class="songs">
<div v-for="fileObject in fileObjects" class="song-container card mb-3"> <div v-for="fileObject in fileObjects" class="song-container card mb-3">
<div class="card-header"> <div class="card-header">
<h3>${ fileObject.name }$</h3> <h3><% fileObject.name %></h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-group mb-3"> <div class="form-group mb-3">
<div v-if="fileObject.artist === '' || fileObject.artist === null" <div v-if="fileObject.artist === '' || fileObject.artist === null" class="alert alert-danger">Please enter an artist for this song.</div>
class="alert alert-danger">Please enter an artist for this song. <input v-if="upload_in_progress || uploaded" type="text" name="artist[]" class="form-control input-sm artist" disabled
</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"/> placeholder="Artist" v-model="fileObject.artist"/>
<input v-else type="text" name="artist[]" <input v-else type="text" name="artist[]" class="form-control input-sm artist"
class="form-control input-sm artist"
placeholder="Artist" v-model="fileObject.artist"/> placeholder="Artist" v-model="fileObject.artist"/>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<div v-if="fileObject.title === '' || fileObject.title === null" <div v-if="fileObject.title === '' || fileObject.title === null" class="alert alert-danger">Please enter a title for this song.</div>
class="alert alert-danger">Please enter a title for this song. <input v-if="upload_in_progress || uploaded" type="text" name="title[]" class="form-control input-sm title" disabled
</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"/> placeholder="Title" v-model="fileObject.title"/>
<input v-else type="text" name="title[]" class="form-control input-sm title" <input v-else type="text" name="title[]" class="form-control input-sm title"
placeholder="Title" v-model="fileObject.title"/> placeholder="Title" v-model="fileObject.title"/>
</div> </div>
<template v-if="fileObject.upload_finished === true"> <template v-if="fileObject.upload_finished === true">
<div v-if="fileObject.success === true" class="alert alert-success">Upload <div v-if="fileObject.success === true" class="alert alert-success">Upload finished successfully.</div>
finished successfully. <div v-else class="alert alert-danger"><% fileObject.error_message %></div>
</div>
<div v-else class="alert alert-danger">${ fileObject.error_message }$</div>
</template> </template>
</div> </div>
</div> </div>
@ -68,20 +56,14 @@
</div> </div>
<div class="card-footer"> <div class="card-footer">
<div class="progress mt-2 mb-3"> <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) }" <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>
class="progress-bar progress-bar-striped" role="progressbar"
:style="{ width: (progress_bar_width + '%') }" aria-valuenow="50" aria-valuemin="0"
aria-valuemax="100"></div>
</div> </div>
<template v-if="upload_in_progress || uploaded"> <template v-if="upload_in_progress || uploaded">
<button v-if="uploaded" class="btn btn-primary btn-block w-100" v-on:click="clear"> <button v-if="uploaded" class="btn btn-primary btn-block w-100" v-on:click="clear">Clear</button>
Clear
</button>
<button v-else class="btn btn-primary btn-block w-100 disabled">Clear</button> <button v-else class="btn btn-primary btn-block w-100 disabled">Clear</button>
</template> </template>
<template v-else> <template v-else>
<input v-if="ready_for_upload" id="upload" class="btn btn-primary btn-block w-100" <input v-if="ready_for_upload" id="upload" class="btn btn-primary btn-block w-100" type="submit" value="Upload" v-on:click="upload"/>
type="submit" value="Upload" v-on:click="upload"/>
<button v-else class="btn btn-primary btn-block w-100 disabled">Upload</button> <button v-else class="btn btn-primary btn-block w-100 disabled">Upload</button>
</template> </template>
</div> </div>
@ -91,21 +73,20 @@
</div> </div>
</div> </div>
<link rel="stylesheet" href="{% static 'songs/css/upload.css' %}"/> <link rel="stylesheet" href="{% static 'songs/css/upload.css' %}"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script> <script type="module">
<script> import * as id3 from '//unpkg.com/id3js@^2/lib/id3.js';
let upload_vue = createApp({
delimiters: ['${', '}$'], let upload_vue = new Vue({
data() { el: '#uploadform',
return { delimiters: ['<%', '%>'],
files: [], data: {
fileObjects: [], files: [],
uploaded: false, fileObjects: [],
upload_in_progress: false, uploaded: false,
files_loading: false, upload_in_progress: false,
}
}, },
computed: { computed: {
ready_for_upload: function () { ready_for_upload: function() {
if (this.uploaded !== false || this.upload_in_progress !== false || this.fileObjects.length === 0) { if (this.uploaded !== false || this.upload_in_progress !== false || this.fileObjects.length === 0) {
return false; return false;
} else { } else {
@ -117,14 +98,14 @@
return true; return true;
} }
}, },
everything_successfully_uploaded: function () { everything_successfully_uploaded: function() {
return this.fileObjects.map((fileObject) => { return this.fileObjects.map((fileObject) => {
return fileObject.upload_finished === true && fileObject.success === true; return fileObject.upload_finished === true && fileObject.success === true;
}).reduce((previousValue, currentValue) => { }).reduce((previousValue, currentValue) => {
return previousValue && currentValue; return previousValue && currentValue;
}, true); }, true);
}, },
progress_bar_width: function () { progress_bar_width: function() {
if (this.fileObjects.length === 0) { if (this.fileObjects.length === 0) {
return 0; return 0;
} }
@ -177,20 +158,14 @@
}).then(() => { }).then(() => {
this.fileObjects[i].success = true; this.fileObjects[i].success = true;
}).catch(e => { }).catch(e => {
console.log(e);
if (e instanceof Response) { if (e instanceof Response) {
try { e.json().then(data => {
e.json().then(data => { this.fileObjects.error_message = data.errorMessage;
this.fileObjects[i].error_message = data.errorMessage; this.fileObjects.success = false;
this.fileObjects[i].success = false; });
});
} catch {
this.fileObjects[i].error_message = "An exception occurred while uploading this file, please try again.";
this.fileObjects[i].success = false;
}
} else { } else {
this.fileObjects[i].error_message = "An exception occurred while uploading this file, please try again."; this.fileObjects.error_message = "An exception occurred while uploading this file, please try again.";
this.fileObjects[i].success = false; this.fileObjects.success = false;
} }
}).finally(() => { }).finally(() => {
this.fileObjects[i].upload_finished = true; this.fileObjects[i].upload_finished = true;
@ -202,25 +177,23 @@
}); });
}, },
async set_new_files(event) { async set_new_files(event) {
this.files_loading = true;
this.uploaded = false;
this.upload_in_progress = false;
this.files = event.target.files; this.files = event.target.files;
let newFileObjects = []; let newFileObjects = [];
for (let i = 0; i < this.files.length; i++) { for (let i = 0; i < this.files.length; i++) {
await this.parseSong(this.files[i]).then((song) => { try {
const tags = await this.parseSong(this.files[i]);
newFileObjects.push( newFileObjects.push(
{ {
"file": this.files[i], "file": this.files[i],
"name": this.files[i].name, "name": this.files[i].name,
"artist": song.artist, "artist": tags.artist,
"title": song.title, "title": tags.title,
"success": null, "success": null,
"error_message": null, "error_message": null,
"upload_finished": false, "upload_finished": false,
} }
); );
}).catch(() => { } catch {
newFileObjects.push( newFileObjects.push(
{ {
"file": this.files[i], "file": this.files[i],
@ -232,28 +205,14 @@
"upload_finished": false, "upload_finished": false,
} }
) )
}); }
} }
this.fileObjects = newFileObjects; this.fileObjects = newFileObjects;
this.files_loading = false;
}, },
async parseSong(file) { parseSong(file) {
let jsMediaTags = window.jsmediatags; return id3.fromFile(file);
const tags = await new Promise((resolve, reject) => {
jsMediaTags.read(file, {
onSuccess: function (tag) {
resolve(tag);
},
onError: function (error) {
reject(error);
}
});
});
return tags.tags;
} }
} }
}).mount('#uploadform'); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -28,7 +28,6 @@
<th>#</th> <th>#</th>
<th>User</th> <th>User</th>
<th style="text-align: right;"># Songs</th> <th style="text-align: right;"># Songs</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -55,7 +54,6 @@
<th>#</th> <th>#</th>
<th>User</th> <th>User</th>
<th style="text-align: right;"># Requests</th> <th style="text-align: right;"># Requests</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -111,7 +109,6 @@
<th>#</th> <th>#</th>
<th>User</th> <th>User</th>
<th style="text-align: right;"># Unique</th> <th style="text-align: right;"># Unique</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -179,9 +176,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Most played uploaders</h2> <h2>Most played uploaders</h2>
<p>These are the {{ stats.stats_top_count }} people whose songs are requested most often by other <p>These are the {{ stats.stats_top_count }} people whose songs are requested most often by other people, as shown in the left column. The right column shows how many times that person has queued his own songs.</p>
people, as shown in the left column. The right column shows how many times that person has queued
their own songs.</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
@ -207,7 +202,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Most played songs last 14 days</h2> <h2>Most played songs last 14 days</h2>
<p>These {{ stats.stats_top_count }} songs have been requested the most in the last two weeks.</p> <p>These songs are played the {{ stats.stats_top_count }} most in the last two weeks.</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>

View File

@ -21,10 +21,9 @@
{% endif %} {% endif %}
<div class="col-md-6"> <div class="col-md-6">
<h2>Most played songs</h2> <h2>Most played songs</h2>
<p>You have requested <strong> {{ stats.unique_requests }} </strong> different songs a total of <p>You have requested <strong> {{ stats.unique_requests }} </strong> different
<strong> {{ stats.total_requests }} </strong> times. This means songs a total of <strong> {{ stats.total_requests }} </strong> times. This
<strong> {% widthratio stats.unique_requests stats.total_requests 100 %}% </strong> of your requests means <strong> {% widthratio stats.unique_requests stats.total_requests 100 %}% </strong> of your requests have been unique. </p>
have been unique. These are the song you have requested the most.</p>
<h4>Top {{ stats.stats_top_count }}:</h4> <h4>Top {{ stats.stats_top_count }}:</h4>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
@ -51,7 +50,6 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Most played artists</h2> <h2>Most played artists</h2>
<p>These are the artists you have requested the most.</p>
<h4>Top {{ stats.stats_top_count }}:</h4> <h4>Top {{ stats.stats_top_count }}:</h4>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
@ -59,7 +57,7 @@
<tr> <tr>
<th>#</th> <th>#</th>
<th>Artist</th> <th>Artist</th>
<th style="white-space:nowrap; text-align: right;"># Requests</th> <th style="text-align: right;"># Requests</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -76,11 +74,11 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Uploads requested</h2> <h2>Uploads requested</h2>
<p>You have uploaded a total of <strong> {{stats.total_uploads }} </strong> songs. The left column <p> You have uploaded a total of <strong> {{stats.total_uploads }} </strong> songs. The left column
shows how many times these have been requested by other people. The right column shows how many times shows how many times these have been requested by other people. The right column shows
you requested your own songs. In total your songs have been queued how many times you requested your own songs. In total your songs
<strong> {{stats.total_played_uploads }} </strong> times by others and have been queued <strong> {{stats.total_played_uploads }} </strong> times by others and
<strong> {{stats.total_played_user_uploads }} </strong> times by yourself.</p> <strong> {{stats.total_played_user_uploads }} </strong> by yourself.
<h4>Top {{ stats.stats_top_count }}:</h4> <h4>Top {{ stats.stats_top_count }}:</h4>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
@ -89,8 +87,8 @@
<th>#</th> <th>#</th>
<th>Artist</th> <th>Artist</th>
<th>Title</th> <th>Title</th>
<th style="white-space:nowrap; text-align: right;"># Others</th> <th style="text-align: right;">Others</th>
<th style="white-space:nowrap;"># You</th> <th>You</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -109,8 +107,8 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Upload artists requested</h2> <h2>Upload artists requested</h2>
<p>The left column shows how many times songs from artists uploaded by you have been requested by <p> The left column shows how many times songs from artists uploaded by you have been requested by
other people. The right column shows how many times you requested those songs.</p> other people. The right column shows how many times you requested those songs.
<h4>Top {{ stats.stats_top_count }}:</h4> <h4>Top {{ stats.stats_top_count }}:</h4>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
@ -118,8 +116,8 @@
<tr> <tr>
<th>#</th> <th>#</th>
<th>Artist</th> <th>Artist</th>
<th style="white-space:nowrap; text-align: right;"># Others</th> <th style="text-align: right;">Others</th>
<th style="white-space:nowrap;"># You</th> <th>You</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -137,15 +135,14 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Most played uploaders</h2> <h2>Most played uploaders</h2>
<p>These are the people whose songs you have requested the most.</p> <p> The people whose songs you have queued the most are:</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Uploader</th> <th>Uploader</th>
<th style="white-space:nowrap; text-align: right;"># Requests</th> <th style="text-align: right;"># Requests</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -163,14 +160,14 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h2>Biggest fans</h2> <h2>Biggest fans</h2>
<p>These are the people that have requested your songs the most.</p> <p> The people that queued your songs the most are:</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>User</th> <th>User</th>
<th style="white-space:nowrap; text-align: right;"># Requests</th> <th style="text-align: right;"># Requests</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -1,5 +1,5 @@
[tool.poetry] [tool.poetry]
name = "marietje" name = "MarietjeDjango"
version = "4.1.0" version = "4.1.0"
description = "A music player for the south canteen of the Huygens building" description = "A music player for the south canteen of the Huygens building"
authors = [ authors = [

View File

@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -e
touch -a /marietje/log/uwsgi.log
touch -a /marietje/log/django.log
cd /marietje/src/website
./manage.py migrate --no-input
./manage.py collectstatic --no-input
chown --recursive www-data:www-data /marietje/
echo "Starting uwsgi server."
uwsgi --chdir=/marietje/src/website \
--module=marietje.wsgi:application \
--master --pidfile=/tmp/project-master.pid \
--socket=:8000 \
--processes=5 \
--uid=www-data --gid=www-data \
--harakiri=20 \
--post-buffering=16384 \
--max-requests=5000 \
--thunder-lock \
--vacuum \
--logfile-chown \
--logto2=/marietje/log/uwsgi.log \
--ignore-sigpipe \
--ignore-write-errors \
--disable-write-exception