19 Commits

Author SHA1 Message Date
82583d21d8 fix: Poetry is picky and needs the module to match the folder 2024-02-21 15:33:39 +01:00
eb1581e41e fix: fix typo in maintainer information 2024-02-21 15:28:38 +01:00
ef250c72a6 Merge branch 'marietje-zuid' into 'feature/marietje-4.1-in-header'
# Conflicts:
#   marietje/queues/templates/queues/queue.html
2024-02-21 15:19:07 +01:00
6110cd6665 fix(songs): change new Vue to createApp 2024-02-21 13:54:41 +01:00
52b24e6b36 fix(queues): check response success besides status 2024-02-21 13:52:45 +01:00
2dd4dd3381 Merge branch 'fix/request-table' into 'marietje-zuid'
Other container layout and responsive queue container

Closes #47

See merge request technicie/MarietjeDjango!67
2024-02-21 12:28:25 +01:00
219af8fa1d Merge branch 'wkuijltjes/aesthetic-changes' into 'marietje-zuid'
Some aesthetic changes to the dark mode colors and the table borders

See merge request technicie/MarietjeDjango!81
2023-11-25 07:45:46 +01:00
98e43aa688 Some aesthetic changes to the dark mode colors and the table borders 2023-11-25 07:45:46 +01:00
831f479eec Merge branch 'wkuijltjes/minor-edits-stats-pages' into 'marietje-zuid'
Slightly improve stats pages

Closes #69

See merge request technicie/MarietjeDjango!80
2023-11-25 07:45:01 +01:00
6a9c22b7f8 Slightly improve stats pages 2023-11-25 07:45:01 +01:00
2d36ace60f Merge branch 'wkuijltjes/reduce-control-column-size' into 'marietje-zuid'
Reduce the width of buttons in the control column and recombine them into one row

Closes #68

See merge request technicie/MarietjeDjango!79
2023-11-25 07:44:45 +01:00
fbafcf1b06 Reduce the width of buttons in the control column and recombine them into one row 2023-11-25 07:44:45 +01:00
e738dc8ab5 Merge master 2023-10-25 17:35:45 +02:00
0e6eaa6076 Merge main 2023-10-14 09:37:37 +02:00
d79e3425f4 New song library 2023-10-12 17:25:26 +02:00
e280fd567d Merge branch 'marietje-zuid' into feature/marietje-4.1-in-header 2023-10-11 17:35:44 +02:00
1f831a6dab Fixed space 2023-10-11 17:32:12 +02:00
8a926f3924 Other container layout and responsive queue container 2023-10-04 20:31:49 +02:00
b2429f941b Marietje in header and fix load times for upload page 2023-10-04 20:00:18 +02:00
29 changed files with 380 additions and 592 deletions

View File

@ -12,7 +12,7 @@ black:
- python3 -m pip install --upgrade pip
- curl -sSL https://install.python-poetry.org | python3 -
- export PATH="/root/.local/bin:$PATH"
- poetry install --with dev --no-root
- poetry install --with dev
script:
- poetry run black --quiet --check marietje

View File

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

View File

@ -7,13 +7,13 @@ from .models import User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
fieldsets = (
(None, {"fields": ("username", "password")}),
(None, {"fields": ("username", "password", "queue")}),
(_("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", "is_staff")
list_display = ("username", "email", "name", "date_joined", "last_login", "queue", "is_staff")
search_fields = ("username", "name", "email")
def delete_model(self, request, user):

View File

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

View File

@ -1,28 +0,0 @@
# 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

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

View File

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

View File

@ -31,19 +31,6 @@ a {
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"] {
background-color: var(--background-shade-light);
border: 1px solid var(--background-shade);
@ -82,6 +69,19 @@ footer {
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 {
color: #777777;
}
@ -91,7 +91,24 @@ footer {
}
.marietjequeue-pre-start td {
border-bottom: 3px double #777777;
border-bottom: 3px double var(--text-color);
}
.marietjequeue-post-start td {
border-top: 3px double var(--text-color);
}
tr.requested_song{
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 {
@ -100,13 +117,10 @@ footer {
transition: 1s transform ease-in-out;
}
.currentsong {
border-bottom: 1px solid #DDDDDD;
}
.navbar-text {
color: var(--text-color);
}
.danger {
color: red !important;
}

View File

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

View File

@ -15,7 +15,10 @@
<link href="{% static 'fontawesomefree/css/all.min.css' %}" rel="stylesheet" type="text/css">
<!-- Vue JS -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp } = Vue;
</script>
<!-- TaTa.js notifications -->
<script src="{% static 'marietje/js/tata.js' %}"></script>
@ -37,6 +40,7 @@
</section>
<nav class="navbar navbar-expand-lg sticky-top navbar-dark bg-primary">
<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"
data-bs-target="#offcanvasNavbar" aria-controls="offcanvasNavbar">
<span class="navbar-toggler-icon"></span>

View File

@ -0,0 +1,82 @@
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

@ -1,29 +0,0 @@
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.views.decorators.csrf import csrf_exempt
from marietje.utils import playlist_song_to_dict
from queues.models import Queue
from songs.models import Song
from .decorators import token_required
from .services import playlist_song_to_dict
@csrf_exempt

View File

@ -1,18 +1,9 @@
from typing import Optional
from django.contrib import admin
from django.contrib.auth import get_user_model
from .models import Queue, Playlist, PlaylistSong, QueueCommand
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)
User = get_user_model()
@admin.register(Queue)
class OrderAdmin(admin.ModelAdmin):
@ -30,61 +21,3 @@ class PlaylistSongAdmin(admin.ModelAdmin):
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,4 +1,3 @@
from django.db.models import Q
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
@ -9,7 +8,7 @@ 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, Queue
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
@ -81,7 +80,7 @@ class QueueSkipAPIView(APIView):
if queue is None:
return Response(status=404)
playlist_song = queue.current_song()
playlist_song = request.user.queue.current_song()
if (
request.user is not None
and playlist_song.user != request.user
@ -91,7 +90,6 @@ class QueueSkipAPIView(APIView):
playlist_song.state = 2
playlist_song.save()
queue.log_action(request.user, "next", "Skipped to next song.")
return Response(status=200, data=QueueSerializer(queue).data)
@ -113,18 +111,7 @@ class PlaylistSongMoveDownAPIView(APIView):
and not request.user.has_perm("queues.can_move")
):
return Response(status=403)
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)
@ -144,18 +131,7 @@ class PlaylistSongCancelAPIView(DestroyAPIView):
and not request.user.has_perm("queues.can_cancel")
):
return Response(status=403)
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)
@ -189,8 +165,6 @@ class QueueRequestAPIView(CreateAPIView):
except RequestException as 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()
return Response(status=200, data=self.serializer_class(playlist_song).data)
@ -222,11 +196,7 @@ class QueueVolumeDownAPIView(APIView):
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")
queue.log_action(request.user, "volume_down", "Reduced the volume of {}.".format(queue))
return Response(status=200, data=self.serializer_class(queue).data)
@ -257,11 +227,7 @@ class QueueVolumeUpAPIView(APIView):
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")
queue.log_action(request.user, "volume_up", "Increased the volume of {}.".format(queue))
return Response(status=200, data=self.serializer_class(queue).data)
@ -292,9 +258,5 @@ class QueueMuteAPIView(APIView):
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")
queue.log_action(request.user, "mute", "Muted the volume of {}.".format(queue))
return Response(status=200, data=self.serializer_class(queue).data)

View File

@ -1,64 +0,0 @@
# 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

@ -1,22 +0,0 @@
# 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,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import Q
from django.conf import settings
@ -7,8 +6,6 @@ from django.utils import timezone
from queues.exceptions import RequestException
from songs.models import Song
User = get_user_model()
class Playlist(models.Model):
def __str__(self):
@ -146,41 +143,10 @@ class Queue(models.Model):
playlist_song.save()
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):
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):
queue = models.ForeignKey(
Queue,
@ -191,20 +157,3 @@ class QueueCommand(models.Model):
def __str__(self):
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,44 +1,15 @@
from typing import Optional
from django.contrib.auth import get_user_model
from queues.models import Queue, Playlist
from queues.models import Queue
from django.conf import settings
User = get_user_model()
def get_user_or_default_queue(request) -> Queue:
"""Get the user or default queue from a request."""
def get_user_or_default_queue(request):
"""Get the user or default queue."""
if request.user is None:
return get_default_queue()
else:
return get_queue_for_user(request.user)
return request.user.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:
def get_default_queue():
"""Get the 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)
return Queue.objects.get(pk=settings.DEFAULT_QUEUE)

View File

@ -1,18 +0,0 @@
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 %}
<nav class="navbar navbar-expand navbar-default navbar-light border-bottom">
<div class="container">
<div class="container-lg">
<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"
@ -39,30 +39,32 @@
</li>
</ul>
<ul v-if="'start_personal_queue' in infobar && infobar.start_personal_queue !== null" id="personal-queue-container" class="navbar-nav navbar-right hidden-xs">
<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">
First song starts in <% infobar.start_personal_queue.secondsToMMSS() %>
</p>
<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() %>
</p>
</li>
<li class="nav-item me-3">
<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() %>
</p>
<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() %>
</p>
</li>
<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>
</li>
<ul 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">
<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() }$
</p>
<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() }$
</p>
</li>
<li class="nav-item me-3">
<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() }$
</p>
<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() }$
</p>
</li>
<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>
</li>
</template>
</ul>
</div>
</nav>
<div class="container">
<div class="container-lg">
<br><br>
<div class="alert-location">
</div>
@ -71,7 +73,7 @@
<div id="queue-container">
<table class="table table-striped">
<thead>
<tr class="table-header-style">
<tr class="table-header-style underline_cell">
<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>
@ -84,40 +86,41 @@
</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>
<tr :class="{ marietjequeue: (song.user === null),
underline_cell: (index === queue[-1]),
currentsong: (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 %>
${ 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() %>
${ song.time_until_song_seconds.secondsToMMSS() }$
</template>
<template v-else-if="playsIn === false && song.plays_at !== null && song.played === false">
<% song.plays_at.timestampToHHMMSS() %>
${ song.plays_at.timestampToHHMMSS() }$
</template>
</td>
<td>
<div class="d-flex flex-column">
<div class="d-flex flex-row">
<button v-if="song.can_move_up" v-on:click="move_down(queue[index-1].id)" class="btn btn-link"><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_up" v-on:click="move_down(queue[index-1].id)"
class="btn btn-link p-1 p-md-2"><i 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-if="song.can_move_down" v-on:click="move_down(song.id)" class="btn btn-link"><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>
<div class="d-flex flex-row">
<button v-if="song.can_delete" v-on:click="cancel_song(song.id)" class="btn btn-link"><i
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>
<button v-if="song.can_move_down" v-on:click="move_down(song.id)"
class="btn btn-link p-1 p-md-2"><i 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-if="song.can_delete" v-on:click="cancel_song(song.id)"
class="btn btn-link p-1 p-md-2"><i class="fa-solid fa-trash-can"></i></button>
<button v-else class="btn btn-link invisible p-1 p-md-2"><i class="fa-solid fa-trash-can"></i></button>
</div>
</div>
</td>
@ -128,7 +131,7 @@
</div>
</div>
<div class="tab-pane fade" id="request" role="tabpanel" aria-labelledby="request-tab">
<div id="request-container">
<div id="request-container" class="table-responsive">
<table id="request-table" class="table table-striped">
<thead>
<tr>
@ -176,7 +179,7 @@
</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>
<option :value="i">${ i }$</option>
</template>
</select>
</th>
@ -187,21 +190,21 @@
<template v-for="(song, index) in songs">
<tr>
<td>
<% song.artist %>
${ song.artist }$
</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>
<template v-if="song.user === null">
Marietje
</template>
<template v-else>
<% song.user.name %>
${ song.user.name }$
</template>
</td>
<td>
<% song.duration.secondsToMMSS() %>
${ song.duration.secondsToMMSS() }$
</td>
<td>
<button v-on:click="report_song(song.id);" class="btn btn-link p-0 text-decoration-none">
@ -225,18 +228,34 @@
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,
const personal_queue_vue = createApp({
delimiters: ['${', '}$'],
data() {
return {
infobar: null,
}
},
}).mount('#personal-queue-container');
const queue_vue = createApp({
delimiters: ['${', '}$'],
data() {
return {
current_song: null,
queue: [],
user_data: null,
refreshing: true,
refreshTimer: null,
clockInterval: null,
started_at: null,
playsIn: true,
}
},
watch: {
playsIn: {
handler(val, oldVal) {
this.update_infobar();
}
},
},
mounted() {
this.clockInterval = setInterval(this.update_song_times, 1000);
@ -302,16 +321,14 @@
plays_in: this.playsIn,
now_in_seconds: 0,
}
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;
infoBar.now_in_seconds = Math.round((new Date()).getTime() / 1000);
// 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;
}
for (let i = 0; i < this.queue.length; 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;
infoBar['length_personal_queue'] -= current_song_remaining_seconds;
infoBar['length_total_queue'] -= current_song_remaining_seconds;
@ -321,11 +338,11 @@
infoBar['length_personal_queue'] += current_song.song.duration;
infoBar['end_personal_queue'] = infoBar['length_total_queue'];
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;
}
}
}
this.$emit("infobar", infoBar);
personal_queue_vue.infobar = infoBar;
},
refresh() {
if (!this.refreshing) {
@ -411,29 +428,20 @@
});
},
}
});
const personal_queue_vue = new Vue({
el: '#personal-queue-container',
delimiters: ['<%', '%>'],
data: {
infobar: [],
},
mounted() {
queue_vue.$on("infobar", infoBar => this.infobar = infoBar);
}
});
}).mount("#queue-container");
</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,
const request_vue = createApp({
delimiters: ['${', '}$'],
data() {
return {
songs: [],
total_songs: 0,
search_input: "",
typing_timer: null,
page_size: 10,
page_number: 1,
}
},
watch: {
search_input: {
@ -537,7 +545,8 @@
"Content-Type": 'application/json',
},
}).then(response => {
if (response.status === 200) {
// TODO: Communicate failure through HTTP error codes (403) instead of checking response.success.
if (response.status === 200 && response.success) {
return response.json();
} else {
throw response;
@ -596,7 +605,7 @@
this.page_number = page_number;
}
}
});
}).mount('#request-container');
</script>
<script>
function volume_down() {

View File

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

View File

@ -1,9 +1,4 @@
import binascii
import socket
import struct
from django.conf import settings
from marietje.utils import send_to_bertha
from queues.models import PlaylistSong
from songs.models import Song
from django.db.models.functions import Coalesce
@ -16,20 +11,6 @@ class UploadException(Exception):
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):
if not ps.played_at:
# Request is from the old times, assume good

View File

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

View File

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

View File

@ -28,6 +28,7 @@
<th>#</th>
<th>User</th>
<th style="text-align: right;"># Songs</th>
<th></th>
</tr>
</thead>
<tbody>
@ -54,6 +55,7 @@
<th>#</th>
<th>User</th>
<th style="text-align: right;"># Requests</th>
<th></th>
</tr>
</thead>
<tbody>
@ -109,6 +111,7 @@
<th>#</th>
<th>User</th>
<th style="text-align: right;"># Unique</th>
<th></th>
</tr>
</thead>
<tbody>
@ -176,7 +179,9 @@
</div>
<div class="col-md-6">
<h2>Most played uploaders</h2>
<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>
<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
their own songs.</p>
<div class="table-responsive">
<table class="table table-striped">
<thead>
@ -202,7 +207,7 @@
</div>
<div class="col-md-6">
<h2>Most played songs last 14 days</h2>
<p>These songs are played the {{ stats.stats_top_count }} most in the last two weeks.</p>
<p>These {{ stats.stats_top_count }} songs have been requested the most in the last two weeks.</p>
<div class="table-responsive">
<table class="table table-striped">
<thead>

View File

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

View File

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