first backend frontend split

with docker compose file
nextjs something something
also includes migrations
This commit is contained in:
2025-06-20 14:06:00 +02:00
parent e7cc031588
commit dfc242084e
138 changed files with 1592 additions and 13 deletions
View File
+36
View File
@@ -0,0 +1,36 @@
from django.contrib import admin
from django.utils.http import urlencode
from django.shortcuts import redirect
from .models import Track, Artist, Album, Background, Vote, Profile
class TrackAdmin(admin.ModelAdmin):
pass
class ArtistAdmin(admin.ModelAdmin):
pass
class AlbumAdmin(admin.ModelAdmin):
pass
class BackgroundAdmin(admin.ModelAdmin):
pass
class VoteAdmin(admin.ModelAdmin):
pass
class ProfileAdmin(admin.ModelAdmin):
pass
class AllTrackAdmin(admin.ModelAdmin):
list_display = ["name", "artist", "old", "from_playlist"]
list_filter = ["old", "from_playlist"]
def get_queryset(self, request):
return (Track.all_tracks.all())
def changelist_view(self, request, extra_context=None):
if 'old__exact' not in request.GET:
default_filter = urlencode({'old__exact': '0'}) # 'True' or 'False'
return redirect(f"{request.path}?{default_filter}")
return super().changelist_view(request, extra_context=extra_context)
#admin.site.register(Track, TrackAdmin)
admin.site.register(Track, AllTrackAdmin)
admin.site.register(Artist, ArtistAdmin)
admin.site.register(Album, AlbumAdmin)
admin.site.register(Background, BackgroundAdmin)
admin.site.register(Vote, VoteAdmin)
admin.site.register(Profile, ProfileAdmin)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PlaylistConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'playlist'
@@ -0,0 +1,48 @@
# Generated by Django 5.0.1 on 2024-02-04 22:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Artist',
fields=[
('id', models.CharField(max_length=128, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('popularity', models.SmallIntegerField(blank=True, null=True)),
('genres', models.JSONField()),
],
),
migrations.CreateModel(
name='Album',
fields=[
('id', models.CharField(max_length=128, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('album_type', models.CharField(max_length=255)),
('total_tracks', models.PositiveSmallIntegerField(null=True)),
('image', models.ImageField(upload_to='albums/')),
('artists', models.ManyToManyField(to='playlist.artist')),
],
),
migrations.CreateModel(
name='Track',
fields=[
('id', models.CharField(max_length=128, primary_key=True, serialize=False)),
('title', models.CharField(max_length=255)),
('explicit', models.BooleanField(default=False)),
('duration_ms', models.PositiveIntegerField(null=True)),
('popularity', models.PositiveIntegerField(null=True)),
('banter', models.TextField()),
('album', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='playlist.album')),
('artists', models.ManyToManyField(to='playlist.artist')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-05 21:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='track',
name='old',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-05 21:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('playlist', '0002_track_old'),
]
operations = [
migrations.RenameField(
model_name='track',
old_name='title',
new_name='name',
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.1 on 2024-02-05 22:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0003_rename_title_track_name'),
]
operations = [
migrations.AlterField(
model_name='artist',
name='genres',
field=models.JSONField(null=True),
),
migrations.AlterField(
model_name='track',
name='banter',
field=models.TextField(blank=True),
),
]
+28
View File
@@ -0,0 +1,28 @@
# Generated by Django 5.0.1 on 2024-02-07 12:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0004_alter_artist_genres_alter_track_banter'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Vote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('points', models.IntegerField()),
('track', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='playlist.track')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'track')},
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-07 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0005_vote'),
]
operations = [
migrations.AddField(
model_name='track',
name='banter_done',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-12 13:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0006_track_banter_done'),
]
operations = [
migrations.AddField(
model_name='album',
name='image_url',
field=models.URLField(null=True),
),
]
@@ -0,0 +1,32 @@
# Generated by Django 5.0.1 on 2024-02-21 13:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0007_album_image_url'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Background',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='wallpapers/')),
('repeat', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('background', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='playlist.background')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.0.1 on 2024-02-21 13:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0008_background_profile'),
]
operations = [
migrations.AddField(
model_name='background',
name='name',
field=models.CharField(default='Wallpaper', max_length=128),
preserve_default=False,
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-21 14:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0009_background_name'),
]
operations = [
migrations.AddField(
model_name='background',
name='color',
field=models.CharField(blank=True, max_length=32),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-21 14:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0010_background_color'),
]
operations = [
migrations.AlterField(
model_name='background',
name='image',
field=models.ImageField(null=True, upload_to='wallpapers/'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-02-21 14:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0011_alter_background_image'),
]
operations = [
migrations.AlterField(
model_name='background',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='wallpapers/'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.0.1 on 2024-02-26 18:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0012_alter_background_image'),
]
operations = [
migrations.AddField(
model_name='profile',
name='last_voted',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='playlist.track'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.1 on 2024-02-26 20:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0013_profile_last_voted'),
]
operations = [
migrations.AddField(
model_name='profile',
name='quota',
field=models.FloatField(default=10.0),
),
migrations.AddField(
model_name='profile',
name='quota_last_updated',
field=models.DateTimeField(auto_now=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.0.1 on 2024-02-26 22:17
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0014_profile_quota_profile_quota_last_updated'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='quota_last_updated',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.0.1 on 2024-03-08 23:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0015_alter_profile_quota_last_updated'),
]
operations = [
migrations.AddField(
model_name='profile',
name='spt_access_token',
field=models.CharField(default='', max_length=128),
),
migrations.AddField(
model_name='profile',
name='spt_refresh_token',
field=models.CharField(default='', max_length=128),
),
migrations.CreateModel(
name='Playlist',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('tracks', models.ManyToManyField(to='playlist.track')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-03-08 23:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0016_profile_spt_access_token_profile_spt_refresh_token_and_more'),
]
operations = [
migrations.AddField(
model_name='profile',
name='spt_expires',
field=models.DateTimeField(default=None, null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.1 on 2024-03-13 08:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0017_profile_spt_expires'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='spt_access_token',
field=models.CharField(default='', max_length=128, null=True),
),
migrations.AlterField(
model_name='profile',
name='spt_refresh_token',
field=models.CharField(default='', max_length=128, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-07-20 06:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0018_alter_profile_spt_access_token_and_more'),
]
operations = [
migrations.AddField(
model_name='vote',
name='skipped',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.0.4 on 2024-07-21 07:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0019_vote_skipped'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='track',
name='nominated_by',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='profile',
name='spt_access_token',
field=models.CharField(blank=True, default='', max_length=256, null=True),
),
migrations.AlterField(
model_name='profile',
name='spt_refresh_token',
field=models.CharField(blank=True, default='', max_length=256, null=True),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.0.4 on 2024-07-22 20:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0020_track_nominated_by_alter_profile_spt_access_token_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='track',
name='from_playlist',
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name='track',
name='album',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='playlist.album'),
),
migrations.AlterField(
model_name='track',
name='banter',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='track',
name='duration_ms',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='track',
name='nominated_by',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='track',
name='popularity',
field=models.PositiveIntegerField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-07-23 11:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlist', '0021_track_from_playlist_alter_track_album_and_more'),
]
operations = [
migrations.AddField(
model_name='background',
name='cover',
field=models.BooleanField(default=False),
),
]
+6
View File
@@ -0,0 +1,6 @@
from .track import Track
from .artist import Artist
from .album import Album
from .vote import Vote
from .profile import Profile, Background
from .playlist import Playlist
+13
View File
@@ -0,0 +1,13 @@
from django.db import models
from .artist import Artist
class Album(models.Model):
def __str__(self):
return self.name
id = models.CharField(primary_key=True, max_length=128)
name = models.CharField(max_length=255)
artists = models.ManyToManyField(Artist)
album_type = models.CharField(max_length=255)
total_tracks = models.PositiveSmallIntegerField(null=True)
image = models.ImageField(upload_to="albums/")
image_url = models.URLField(null=True)
+9
View File
@@ -0,0 +1,9 @@
from django.db import models
class Artist(models.Model):
def __str__(self):
return self.name
id = models.CharField(primary_key=True, max_length=128)
name = models.CharField(max_length=255)
popularity = models.SmallIntegerField(null=True, blank=True)
genres = models.JSONField(null=True)
+8
View File
@@ -0,0 +1,8 @@
from django.db import models
from .track import Track
class Playlist(models.Model):
def __str__(self):
return self.name
name = models.CharField(max_length=255)
tracks = models.ManyToManyField(Track)
+145
View File
@@ -0,0 +1,145 @@
from django.db import models
from django.contrib.auth.models import User
from .track import Track
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from datetime import datetime, timedelta, UTC
from ..spotify import spt
import os
from dotenv import load_dotenv
load_dotenv()
NOMINATIONS_PER_DAY = 10
MAX_QUOTA = 22
class Background(models.Model):
def __str__(self):
return self.name
name = models.CharField(max_length=128)
image = models.ImageField(upload_to="wallpapers/", null=True, blank=True)
repeat = models.BooleanField(default=False)
cover = models.BooleanField(default=False)
color = models.CharField(max_length=32, blank=True)
class Profile(models.Model):
def __str__(self):
return str(self.user)
user = models.OneToOneField(User, on_delete=models.CASCADE)
background = models.ForeignKey(Background, null=True, on_delete=models.SET_NULL)
last_voted = models.ForeignKey(Track, null=True, on_delete=models.SET_NULL)
quota = models.FloatField(default=10.0)
quota_last_updated = models.DateTimeField(default=timezone.now, null=False)
spt_access_token = models.CharField(max_length=256, default="", blank=True, null=True)
spt_refresh_token = models.CharField(max_length=256, default="", blank=True, null=True)
spt_expires = models.DateTimeField(default=None, null=True)
@property
def quota_display(self):
return int(self.quota)
def update_quota(self):
elapsed = timezone.now() - self.quota_last_updated
if elapsed.total_seconds() > 300:
noms_per_second = NOMINATIONS_PER_DAY/(60*60*24)
self.quota += noms_per_second * elapsed.total_seconds()
self.quota = min(self.quota, MAX_QUOTA)
print(f"{self.user.username}: Er zijn {elapsed.total_seconds()}s voorbij, je nieuwe quota is {self.quota}")
self.quota_last_updated = timezone.now()
@property
def can_nominate(self):
"""Is this user allowed to nominate at this point in time?"""
end_date = os.getenv('DATE_NOM_END')
if self.user.is_superuser:
return (True, None)
if end_date:
end_date = datetime.fromisoformat(end_date)
if end_date <= datetime.now(tz=UTC):
return (False, "over")
if self.quota < 1:
return (False, "quota")
return (True, None)
@property
def can_vote(self):
"""Is this user allowed to vote at this point in time?"""
end_date = os.getenv('DATE_VOTE_END')
if self.user.is_superuser or not end_date:
return True
end_date = datetime.fromisoformat(end_date)
if end_date <= datetime.now(tz=UTC):
return False
return True
@property
def vote_end(self):
return os.getenv('DATE_VOTE_END')
@property
def nominate_end(self):
return os.getenv('DATE_NOM_END')
def init_spt(self, code):
"""Initial setting of spotify tokens from user accept code"""
data = spt.get_token_json(
grant_type='authorization_code',
code=code
)
self.save_spt(data)
def refresh_spt(self):
"""Refreshes expired access token using stored refresh token"""
if self.spt_refresh_token:
data = spt.get_token_json(
grant_type='refresh_token',
refresh_token=self.spt_refresh_token
)
self.save_spt(data)
def save_spt(self, data):
"""Saves tokens from json response"""
access = data.get('access_token', None)
refresh = data.get('refresh_token', self.spt_refresh_token)
expires = data.get('expires_in', 3600)
self.spt_access_token = access
self.spt_refresh_token = refresh
self.spt_expires = timezone.now() + timedelta(seconds=expires - 300)
self.save()
def unlink_spt(self):
self.spt_access_token = None
self.spt_refresh_token = None
self.spt_expires = None
@property
def can_spt(self):
return (
self.spt_access_token and
self.spt_expires and
(timezone.now() < self.spt_expires)
)
def get_or_update_spt(self):
if self.spt_refresh_token:
if not self.can_spt:
self.refresh_spt()
return self.spt_access_token
return None
def save(self, *args, **kwargs):
self.update_quota()
super().save(*args, **kwargs)
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
Profile.objects.get_or_create(user=instance)
instance.profile.save()
+144
View File
@@ -0,0 +1,144 @@
from django.db import models
from django.contrib.auth.models import User
from random import choice
from .album import Album
from .artist import Artist
from .vote import Vote
from playlist.spotify import spt
#from playlist.utils import track_from_json
import re
class TrackManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(old=False)
def from_json(self, cls, json):
try:
return cls.objects.get(pk=json["id"])
except cls.DoesNotExist:
keys = [f.name for f in cls._meta.get_fields()]
subset = {key:json[key] for key in set(keys) & set(json.keys())}
return cls.objects.create(**subset)
def track_from_json(self, json):
"""Creates the track, album and artist from spotify response."""
# Album artists
album_artists = []
for artist in json["album"]["artists"]:
album_artists.append(self.from_json(Artist, artist))
del json["album"]["artists"]
# Album
album = self.from_json(Album, json["album"])
album.image_url = json["album"]["images"][0]["url"]
album.save()
del json["album"]
for artist in album_artists:
album.artists.add(artist)
# Track artists
track_artists = []
for artist in json["artists"]:
track_artists.append(self.from_json(Artist, artist))
del json["artists"]
# Track
track = self.from_json(Track, json)
for artist in track_artists:
track.artists.add(artist)
track.album = album
track.save()
return track
def create_from_spotify(self, spotify_link, profile):
if re.match(r"^https:\/\/spotify.link.*", spotify_link):
spotify_link = spt.unshort(spotify_link)
m = re.match(r"^https:\/\/open.spotify.com\/track\/([a-zA-Z0-9]+)\??", spotify_link)
if m is None:
return None, Exception("Invalid Spotify track URL")
spotify_id = m.group(1)
try:
existing_track = self.model.all_tracks.get(pk=spotify_id)
if existing_track.old:
existing_track.old = False
existing_track.user = profile.user
existing_track.save()
return (existing_track, "Updated old track")
else:
return (None, "Track already exists")
except:
pass
try:
json = spt.get_song_info(spotify_id)
except Exception as e:
print(e)
return (None, "Error fetching track")
#return TemplateResponse(request, "Nominate.html", {"message_type": "error", "message": "Fout bij het ophalen van het liedje."})
track = self.track_from_json(json)
track.nominated_by = profile.user
track.save()
dup = track.is_duplicate_of()
if dup:
track.delete()
return (None, "Track already exists")
#get_banter.delay(track.id)
profile.quota -= 1
profile.save()
return (track, None)
class Track(models.Model):
def __str__(self):
s = f"'{self.name}'"
if self.artists.count() > 0:
artists = list(map(lambda a:a.name, self.artists.all()))
s += " door "
s += " & ".join(artists)
return s
@property
def artist(self):
if self.artists.count() > 0:
artists = list(map(lambda a:a.name, self.artists.all()))
return " & ".join(artists)
@property
def name_sanitized(self):
return re.match("[^\-\(]*\w", self.name)[0]
@property
def link(self):
return f'https://open.spotify.com/track/{self.id}'
@property
def score(self):
return Vote.objects.filter(track=self.id).aggregate(rating=models.Avg("points"))["rating"]
def is_duplicate_of(self):
tracks = Track.objects.exclude(id=self.id).filter(name__startswith=self.name_sanitized)
artists = [artist.name for artist in self.artists.all()]
for artist in artists:
tracks = tracks.filter(artists__name__exact=artist)
return tracks.first()
id = models.CharField(primary_key=True, max_length=128)
nominated_by = models.ForeignKey(User, blank=True, null=True, default=None, on_delete=models.SET_NULL)
name = models.CharField(max_length=255)
artists = models.ManyToManyField(Artist)
album = models.ForeignKey(Album, blank=True, null=True, on_delete=models.SET_NULL)
explicit = models.BooleanField(default=False)
duration_ms = models.PositiveIntegerField(blank=True, null=True)
popularity = models.PositiveIntegerField(blank=True, null=True)
banter = models.TextField(null=True, blank=True)
banter_done = models.BooleanField(default=False)
old = models.BooleanField(default=False)
from_playlist = models.CharField(max_length=255, null=True, blank=True, default=None)
objects = TrackManager()
all_tracks = models.Manager()
+11
View File
@@ -0,0 +1,11 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
class Vote(models.Model):
class Meta:
unique_together = ["user", "track"]
track = models.ForeignKey('Track', on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
points = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
skipped = models.BooleanField(default=False)
+6
View File
@@ -0,0 +1,6 @@
from .track import *
from .artist import *
from .user import *
from .album import *
from .profile import *
from .vote import *
+12
View File
@@ -0,0 +1,12 @@
from django.db.models.functions import Extract
from playlist.models import Album
from rest_framework import serializers
class AlbumSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Album
fields = '__all__'
extra_kwargs = {
'url': {'view_name': 'api-album', 'lookup_field': 'id'},
'artists': {'view_name': 'api-artist', 'lookup_field': 'id'},
}
+10
View File
@@ -0,0 +1,10 @@
from playlist.models import Artist
from rest_framework import serializers
class ArtistSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Artist
fields = ['id', 'name', 'url']
extra_kwargs = {
'url': {'view_name': 'api-artist', 'lookup_field': 'id'},
}
+20
View File
@@ -0,0 +1,20 @@
from playlist.models import Profile, Background
from rest_framework import serializers
class BackgroundSerializer(serializers.ModelSerializer):
class Meta:
model = Background
fields = '__all__'
class ProfileSerializer(serializers.ModelSerializer):
background = BackgroundSerializer()
class Meta:
model = Profile
fields = '__all__'
class ProfileUpdateSerializer(serializers.ModelSerializer):
background = serializers.PrimaryKeyRelatedField(many=False, queryset=Background.objects.all())
class Meta:
model = Profile
fields = ['background']
+13
View File
@@ -0,0 +1,13 @@
from playlist.models import Track
from rest_framework import serializers
class TrackSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Track
fields = '__all__'
extra_kwargs = {
'url': {'view_name': 'api-track', 'lookup_field': 'id'},
'artists': {'view_name': 'api-artist', 'lookup_field': 'id'},
'album': {'view_name': 'api-album', 'lookup_field': 'id'},
'nominated_by': {'view_name': 'api-user', 'lookup_field': 'id'}
}
+17
View File
@@ -0,0 +1,17 @@
from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.HyperlinkedModelSerializer):
# Add explicit reverse lookup to profile
profile = serializers.HyperlinkedRelatedField(
view_name='api-profile',
lookup_field='id',
read_only=True
)
class Meta:
model = User
fields = ['url', 'id', 'username', 'email', 'profile']
extra_kwargs = {
'url': {'view_name': 'api-user', 'lookup_field': 'id'}
}
+28
View File
@@ -0,0 +1,28 @@
from rest_framework import serializers
from playlist.models import Vote, Track
class VoteSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Vote
fields = '__all__'
extra_kwargs = {
'url': {'view_name': 'api-vote', 'lookup_field': 'id'},
'track': {'view_name': 'api-track', 'lookup_field': 'id'},
'user': {'view_name': 'api-user', 'lookup_field': 'id'}
}
class VoteSaveSerializer(serializers.ModelSerializer):
class Meta:
model = Vote
fields = ['track', 'points', 'skipped']
def create(self, validated_data):
"""
Create a new vote. Deletes a previous skipped vote if it exists.
"""
user = self.context['request'].user
track = validated_data['track']
previous_vote = Vote.objects.filter(user=user, track=track).first()
if previous_vote and previous_vote.skipped:
previous_vote.delete()
return Vote.objects.create(**validated_data)
+105
View File
@@ -0,0 +1,105 @@
import requests
import os
import re
from datetime import datetime, timedelta
from dotenv import load_dotenv
from django.urls import reverse
from django.conf import settings
load_dotenv()
class Spotify():
def __init__(self):
self.token = None
self.valid_until = None
def redirect_uri(self):
return settings.CURRENT_HOST + reverse('spotify-callback')
def token_valid(self):
if self.token and self.valid_until and (datetime.now() < self.valid_until):
return True
return False
def get_token_json(self, grant_type, code=None, refresh_token=None):
url = 'https://accounts.spotify.com/api/token'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + os.getenv('SPOTIFY_ENCODED_ID')
}
data = {
'grant_type': grant_type
}
if code:
data['code'] = code
data['redirect_uri'] = self.redirect_uri()
if refresh_token:
data['refresh_token'] = refresh_token
r = requests.post(url, headers=headers, data=data)
return r.json()
def get_system_token(self):
if self.token_valid():
return self.token
data = self.get_token_json('client_credentials')
self.token = data["access_token"]
self.valid_until = datetime.now() + timedelta(seconds=data["expires_in"]-300)
return self.token
def get_user_token(self, code):
data = self.get_token_json('authorization_code', code, redirect=True)
print(data)
return data
return (data.get('access_token', None), data.get('refresh_token', None))
def get_oauth_redirect(self):
scopes = ['user-read-private', 'user-read-email']
CLIENT_ID = 'bcc523219d1d4248a7e8892e809a5767' #TODO: delet.
url = f'https://accounts.spotify.com/authorize?'
url += f'client_id={CLIENT_ID}&'
url += f'response_type=code&'
url += f'scope={"%20".join(scopes)}&'
url += f'redirect_uri={self.redirect_uri()}'
print(url)
return url
def unshort(self, url):
"""Converts a spotify.link url into a normal spotify URL with ID"""
r = requests.get(url, allow_redirects=False)
r.raise_for_status()
return r.headers["Location"]
def get_song_info(self, spotify_id):
token = self.get_system_token()
url = 'https://api.spotify.com/v1/tracks/' + spotify_id
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
}
r = requests.get(url, headers=headers)
r.raise_for_status()
return r.json()
def get_profile(self, access_token):
url = 'https://api.spotify.com/v1/me'
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + access_token
}
r = requests.get(url, headers=headers)
r.raise_for_status()
return r.json()
def get_playlist_info(self, playlist_id):
token = self.get_system_token()
url = 'https://api.spotify.com/v1/playlists/' + playlist_id
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
}
r = requests.get(url, headers=headers)
r.raise_for_status()
return r.json()
spt = Spotify()
+7
View File
@@ -0,0 +1,7 @@
Copyright 2020 Jordan Scales
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+51
View File
@@ -0,0 +1,51 @@
/* Based on https://codepen.io/agalliat/pen/mdedZYK */
.window {
display: none;
}
body {
display: block;
background-color: #000084;
font-family: "Pixelated MS Sans Serif", Arial;
color: #bbb;
font-size: 1rem;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
}
#bsod {
display: block !important;
width: max(70vw, 400px);
max-width: 800px;
flex-shrink: 1;
gap: 1rem;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
align-items: flex-start;
}
#bsod h1 {
font-size: 1rem;
background-color: #bbb;
color: #000084;
padding: 1rem;
box-shadow: 1rem 1rem black;
margin: 2rem;
text-align: center;
align-self: center;
}
#bsod div {
margin: 0.5rem 0;
}
#bsod .continue {
text-align: center;
font-size: 1.5rem;
margin: 2rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="prev.svg"
inkscape:export-xdpi="25.4"
inkscape:export-ydpi="25.4"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="3.9999988"
transform="matrix(-0.742144,0,0,1.2854311,26.003456,-8.0473449)" />
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1-5"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="3.9999988"
transform="matrix(-0.742144,0,0,1.2854311,50.003456,-8.0473447)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="back.svg"
inkscape:export-xdpi="25.4"
inkscape:export-ydpi="25.4"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="9.8473034"
sodipodi:cy="6.337101"
sodipodi:r1="31.498077"
sodipodi:r2="15.749038"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.5707963"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 37.125439,22.08614 -54.556271,0 27.2781355,-47.247116 z"
inkscape:transform-center-y="-5.3333328"
transform="matrix(0.87982554,0,0,0.67729001,23.336091,21.041278)" />
<rect
style="fill:#808080;stroke-width:0.295996"
id="rect1"
width="48"
height="16"
x="8"
y="42" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="next.svg"
inkscape:export-xdpi="25.4"
inkscape:export-ydpi="25.4"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="-3.9999985"
transform="matrix(0.742144,0,0,1.2854311,37.996544,-8.0473449)" />
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1-5"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="-3.9999988"
transform="matrix(0.742144,0,0,1.2854311,13.996544,-8.0473447)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

+76
View File
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="prev.svg"
inkscape:export-xdpi="25.4"
inkscape:export-ydpi="25.4"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="-3.9999985"
transform="matrix(0.742144,0,0,1.2854311,37.996544,-8.0473449)" />
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1-5"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="-3.9999988"
transform="matrix(0.742144,0,0,1.2854311,13.996544,-8.0473447)" />
<rect
style="fill:#808080;stroke-width:0.223385"
id="rect1"
width="8"
height="48"
x="-59.999992"
y="8"
transform="scale(-1,1)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="pause.svg"
inkscape:export-xdpi="12.7"
inkscape:export-ydpi="12.7"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
id="rect2"
width="16"
height="48"
x="8"
y="8"
style="fill:#808080;stroke-width:0.304549" />
<rect
id="rect2-5"
width="16"
height="48"
x="40"
y="8"
style="fill:#808080;stroke-width:0.304549" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="play.svg"
inkscape:export-xdpi="12.7"
inkscape:export-ydpi="12.7"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
id="rect2"
width="16"
height="48"
x="8"
y="8"
style="stroke-width:0.304549" />
<rect
id="rect2-5"
width="16"
height="48"
x="40"
y="8"
style="stroke-width:0.304549" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="play.svg"
inkscape:export-xdpi="12.7"
inkscape:export-ydpi="12.7"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
id="path2"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="3.9214177"
sodipodi:cy="28.775017"
sodipodi:r1="30.64756"
sodipodi:r2="15.32378"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 34.568978,28.775017 -45.97134,26.541565 0,-53.0831308 z"
inkscape:transform-center-x="-8.0000004"
style="fill:#808080;stroke-width:0.264583"
transform="matrix(1.0441288,0,0,0.90424206,19.905535,5.9804195)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
id="path2"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="3.9214177"
sodipodi:cy="28.775017"
sodipodi:r1="30.64756"
sodipodi:r2="15.32378"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 34.568978,28.775017 -45.97134,26.541565 0,-53.0831308 z"
inkscape:transform-center-x="-8.0000004"
style="stroke-width:0.264583"
transform="matrix(1.0441288,0,0,0.90424206,19.905535,5.9804195)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="back.svg"
inkscape:export-xdpi="25.4"
inkscape:export-ydpi="25.4"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="3.9999988"
transform="matrix(-0.742144,0,0,1.2854311,26.003456,-8.0473449)" />
<path
sodipodi:type="star"
style="fill:#808080;stroke-width:0.264583"
id="path1-5"
inkscape:flatsided="true"
sodipodi:sides="3"
sodipodi:cx="2.6995511"
sodipodi:cy="31.154797"
sodipodi:r1="21.559158"
sodipodi:r2="10.779579"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 24.258709,31.154797 -32.3387371,18.670778 0,-37.341557 z"
inkscape:transform-center-x="3.9999988"
transform="matrix(-0.742144,0,0,1.2854311,50.003456,-8.0473447)" />
<rect
style="fill:#808080;stroke-width:0.223385"
id="rect1"
width="8"
height="48"
x="4"
y="8" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="21"
height="21"
fill="none"
version="1.1"
id="svg4"
sodipodi:docname="svg.svg"
inkscape:export-filename="seek.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4" />
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
showgrid="false" />
<path
d="M 0,0 V 1 H 20 V 20 H 0 v 1 H 21 V 0 Z"
style="fill:#000000;stroke-width:1.48889"
id="path10" />
<path
d="M 0,1 V 20 H 20 V 1 Z M 5,5 H 17 V 17 H 5 Z"
style="fill:#87888f;stroke-width:4.57988"
id="path11"
sodipodi:nodetypes="cccccccccc" />
<path
d="M 0,0 V 19 H 1 V 1 H 20 V 0 Z"
style="fill:#ffffff;stroke-width:1.3985"
id="path9"
sodipodi:nodetypes="ccccccc" />
<path
d="M 1,1 V 19 H 19 V 1 Z M 4,4 H 16 V 16 H 4 Z"
style="fill:#c0c7c8;stroke-width:1.49631"
id="path8"
sodipodi:nodetypes="cccccccccc" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120mm"
height="18.999998mm"
viewBox="0 0 120 18.999998"
version="1.1"
id="svg1"
inkscape:export-filename="slider.svg"
inkscape:export-xdpi="25.4"
inkscape:export-ydpi="25.4"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 0,19 120,0"
id="path18"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="stop.svg"
inkscape:export-xdpi="12.7"
inkscape:export-ydpi="12.7"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
id="rect2"
width="48"
height="49.048836"
x="8"
y="8"
style="fill:#808080;stroke-width:0.533226" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:export-filename="pause.svg"
inkscape:export-xdpi="12.7"
inkscape:export-ydpi="12.7"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
id="rect2"
width="48"
height="49.048836"
x="8"
y="8"
style="stroke-width:0.533226" />
<rect
id="rect2-5"
width="16"
height="48"
x="40"
y="8"
style="stroke-width:0.304549" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="11"
height="21"
fill="none"
version="1.1"
id="svg4"
sodipodi:docname="svg.svg"
inkscape:export-filename="vol-select.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4" />
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
showgrid="false" />
<rect
style="fill:#000000;fill-opacity:1;stroke-width:1.07758"
id="rect7"
width="11"
height="21"
x="0"
y="0" />
<rect
style="fill:#87888f;fill-opacity:1;stroke-width:3.23846"
id="rect4"
width="10"
height="19"
x="0"
y="1" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke-width:0.962516"
id="rect6"
width="9"
height="19"
x="0"
y="0" />
<rect
style="fill:#c0c7c8;fill-opacity:1;stroke-width:0.997539"
id="rect5"
width="8"
height="18"
x="1"
y="1" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke-width:1.24518"
id="rect8"
width="1.3213186"
height="1"
x="8.6786814"
y="0" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

+38
View File
@@ -0,0 +1,38 @@
// Thanks to https://codepen.io/rebelchris/pen/abZRpqM
let timer = null;
function startCountdown(dateString) {
if (timer) clearInterval(timer);
let cd = document.getElementById("countdown")
if (cd) {
cd.classList.remove("hide");
if (dateString == "None") cd.classList.add("hide");
}
const end = new Date(dateString).getTime();
const daysEl = document.getElementById('days');
const hoursEl = document.getElementById('hours');
const minutesEl = document.getElementById('minutes');
const secondsEl = document.getElementById('seconds');
const seconds = 1000;
const minutes = seconds * 60;
const hours = minutes * 60;
const days = hours * 24;
timer = setInterval(updateTime, seconds);
function updateTime() {
if (daysEl) {
let now = new Date().getTime();
const difference = end - now;
if (difference < 0) {
if (timer) clearInterval(timer);
cd.classList.add("hide");
return;
}
daysEl.innerText = Math.floor(difference / days);
hoursEl.innerText = Math.floor( (difference % days) / hours );
minutesEl.innerText = Math.floor( (difference % hours) / minutes );
secondsEl.innerText = Math.floor( (difference % minutes) / seconds );
}
}
updateTime();
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.
+549
View File
@@ -0,0 +1,549 @@
body {
background-color: #01817F;
margin: 0;
height: 100%;
width: 100%;
background-position: 50% 50%;
}
#main-window {
position: fixed;
z-index:99;
min-height: 520px;
width:560px;
overflow:hidden;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
margin: 10px;
}
@media (max-width: 600px) {
#main-window {
margin: 0;
transform: none !important;
left: 0;
width: calc(100% - 6px);
}
#main-window > .window-body {
min-height: calc(100vh - 42px);
}
}
input[type=submit] {
filter: contrast(1);
}
input#spotify-input {
width: 100%;
margin-bottom: 10px;
}
input#spotify-input + input[type=submit]{
background-image: url(img/sound.png);
background-repeat:no-repeat;
padding: 4px 8px 4px 24px;
height: 16px;
min-width: 0;
background-position: 4px 50%;
font-weight: bold;
}
.tree-view.overview {
height: 384px;
overflow-y: scroll;
}
#bsod, .hide {
display: none !important;
}
.offscreen {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.window.popup {
position: fixed;
z-index: 100;
top: calc(50% - 75px);
left: calc(50% - 150px);
width: 300px;
}
.window.popup .title-bar-text {
height: 13px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.window.popup.login {
width: 380px;
left: calc(50% - 190px);
}
.window.popup .window-body {
display: flex;
flex-flow: row nowrap;
}
.window.popup .window-body .text {
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
}
.window.popup .window-body .icon {
width: 32px;
height: 32px;
padding: 16px;
background-position: center center;
background-repeat: no-repeat;
background-image: url('img/check.png');
}
.window.popup .window-body .icon.login {
background-image: url('img/login.png');
background-attachment:unset;
}
.window.popup .window-body .icon.error {
background-image: url('img/error.png');
}
.window.popup .window-body .icon.warning {
background-image: url('img/warning.png');
}
.window {
flex-grow: 1;
flex-shrink: 0;
max-width:calc(100% - 5px);
}
.window.hide {
display:none;
}
.window form {
margin: 0;
}
.window-body {
flex-grow: 1;
display: flex;
flex-flow: column nowrap;
}
/* tab menu fix */
menu[role="tablist"] > li > a {
padding: 6px;
margin: 0;
}
iframe {
opacity: 0;
position: fixed;
}
iframe.hide {
left: 100%;
}
/* Media player */
#album {
background: black;
width: 100%;
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
}
#album h1, #album h2 {
color: white;
margin: 0;
overflow: hidden;
max-width: 100%;
white-space: nowrap;
text-overflow:ellipsis;
}
#album h1 {
font-size: 2rem;
}
#album h2 {
font-size: 1.5rem;
font-weight: normal;
}
#album img {
width: 256px;
}
#seek {
height: 22px;
}
#seek::-moz-range-thumb {
background:transparent;
background-image: url(img/seek.svg);
width: 21px;
transform: none;
}
input[type="range"]#seek::-webkit-slider-thumb {
background:transparent;
background-image: url(img/seek.svg);
width: 21px;
transform: translateX(-5px) translateY(-4px);
}
input[type="range"]#seek::-webkit-slider-runnable-track{
height: 11px;
background: transparent;
box-shadow: 1px 0 0 white, 1px 1px 0 white, 0 1px 0 white, -1px 0 0 #461c1c, -1px -1px 0 #333, -1px 1px black, 1px -1px #a460a4
}
#seek::-moz-range-track {
height: 11px;
background: transparent;
box-shadow: 1px 0 0 white, 1px 1px 0 white, 0 1px 0 white, -1px 0 0 #461c1c, -1px -1px 0 #333, -1px 1px black, 1px -1px #a460a4
}
#media-control {
display:flex;
}
#media-control button {
min-width: 0;
width: 32px;
height: 32px;
background-size: 50%;
background-repeat: no-repeat;
background-position: 50% 50%;
box-shadow:none;
}
#media-control:not(.waiting) button:not(.dummy):hover {
box-shadow: inset -1px -1px #0a0a0a,inset 1px 1px #fff,inset -2px -2px grey,inset 2px 2px #dfdfdf
}
#media-control .spacer {
display: inline-block;
height: 24px;
width: 1px;
margin: 4px 5px;
background: gray;
}
#media-control button#play {
background-image:url(img/play.svg);
}
#media-control button#pause {
background-image:url(img/pause.svg);
}
#media-control button#stop {
background-image:url(img/stop.svg);
}
#media-control.waiting button#play {
background-image:url(img/play-inactive.svg);
}
#media-control.waiting button#pause {
background-image:url(img/pause-inactive.svg);
}
#media-control.waiting button#stop {
background-image:url(img/stop-inactive.svg);
}
#media-control button#prev {
background-image:url(img/prev.svg);
}
#media-control button#back {
background-image:url(img/back.svg);
}
#media-control button#forward {
background-image:url(img/forward.svg);
}
#media-control button#next {
background-image:url(img/next.svg);
}
#media-control button#eject {
background-image:url(img/eject.svg);
}
#media-control #volume {
background-image:url(img/volume.png);
background-repeat:no-repeat;
padding-left: 24px;
background-position: 0 50%;
max-width: 120px;
}
#media-control #volume.playing {
background-image:url(img/pawel.png);
}
#media-control #volume.muted {
background-image:url(img/muted.png);
}
#volume::-moz-range-thumb {
background: transparent;
background-image:url(img/vol-select.svg);
}
input[type="range"]#volume::-webkit-slider-thumb {
background: transparent;
-webkit-appearance: none;
background-image:url(img/vol-select.svg);
transform: translateY(1px);
}
#volume::-moz-range-track {
background: none;
background-image: url(img/slider.svg);
background-position: 0 50%;
background-size: contain;
background-repeat:no-repeat;
box-shadow: none;
border: none;
border-bottom: 1px solid white;
border-right: 1px solid white;
height: 19px;
}
#volume::-webkit-slider-runnable-track {
background: none;
background-image: url(img/slider.svg);
background-position: 0 50%;
background-size: contain;
background-repeat:no-repeat;
box-shadow: none;
border: none;
border-bottom: 1px solid white;
border-right: 1px solid white;
height: 19px;
}
/* Clippy */
#clippy {
position: fixed;
z-index:99;
display: flex;
flex-flow: column nowrap;
align-items: flex-end;
width: 256px;
left: 580px;
top: 0;
height:580px;
justify-content: flex-end;
}
#clippy .body {
background: #ffffe0;
color: black;
padding: 10px;
border-radius: 10px;
border: 1px solid black;
position: relative;
}
#clippy .body .content {
max-height: 375px;
overflow-y: scroll;
}
@media (max-width: 500px) {
#clippy {
width: calc(100vw - 10px);
top: unset;
left: unset;
transform: none !important;
bottom: 0;
right: 0;
height: unset;
}
#clippy .body.hide {
display: none;
}
.body.hide ~ img {
display:none;
}
}
#clippy .body.hide {
display: none;
}
#clippy .body::after {
content: "";
position: absolute;
bottom: -13px;
right: 128px;
border-width: 13px 17px 0;
border-style: solid;
border-color: lightyellow transparent;
display: block;
width: 0;
}
#clippy .body::before {
content: "";
position: absolute;
bottom: -14px;
right: 127px;
border-width: 14px 19px 0;
border-style: solid;
border-color: black transparent;
display: block;
width: 0;
}
#clippy .body button {
background: lightyellow;
border: 1px #999 solid;
border-radius: 2px;
box-shadow: none;
margin-top: 5px;
}
#clippy img {
width: 128px;
}
#vote-stars {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
#vote-stars .star-wrapper {
height: 31px;
width:160px;
}
#vote-stars a {
cursor:pointer;
display: inline-block;
width: 32px;
height: 32px;
margin-right: -2px;
background-image: url(img/star-inactive.png);
}
#vote-stars .star-wrapper:hover a {
background-image: url(img/star.png);
}
#vote-stars a:hover {
background-image: url(img/star.png);
}
#vote-stars a:hover ~ a {
background-image: url(img/star-inactive.png) !important;
}
#average-stars {
width: 200px;
}
#average-stars .stars {
background-image: url(img/star-inactive.png);
background-repeat: repeat-x;
height: 32px;
width: 160px;
position: relative;
}
#average-stars .stars-overlay {
background-image: url(img/star.png);
background-repeat: repeat-x;
height: 32px;
width: 160px;
position: absolute;
}
.undo-wrapper {
display: flex;
flex-flow: row nowrap;
justify-content: center;
flex-grow: 1;
align-items: center;
}
.undo-wrapper p {
flex-grow: 1;
padding: 0 5px;
text-align: right;
}
.undo {
background-image: url(img/undo.png);
background-repeat: no-repeat;
background-position: 50% 0;
padding: 32px 0 4px 0;
height: 50px;
min-width: 40px;
}
.skip {
background-image: url(img/download.png);
background-repeat: no-repeat;
background-position: 50% 20%;
padding: 32px 0 4px 0;
height: 50px;
min-width: 40px;
margin-left: 5px;
}
.progress-indicator {
height: 26px;
padding: 4px 3px;
width: 340px;
}
.field-row.settings label {
width: 200px;
}
.field-row.settings {
margin-bottom: 8px;
}
.settings .icon {
height: 40px;
background-repeat: no-repeat;
background-position: 50% 50%;
width: 40px;
min-width: 0;
}
.settings .admin-panel {
background-image: url(img/gears.png);
}
.settings .spotify {
background-image: url(img/spotify.png);
background-size: 32px;
}
.settings #spotify-cancel {
background-image: url(img/spotify-cancel.png);
background-size: 16px;
background-position: 50% 50%;
background-repeat: no-repeat;
min-width: 0;
}
+73
View File
@@ -0,0 +1,73 @@
from celery import shared_task
from .models import Track
from django.core.files import File
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.core.files.base import ContentFile
import os
import dotenv
import json
from requests import get, post
from requests.exceptions import ConnectionError
from PIL import Image
from io import BytesIO
dotenv.load_dotenv()
@shared_task
def get_banter(id):
"""
Requests an AI model for some banter.
Currently expects an ollama instance to be running,
but might be possible to use OpenAI API in future.
"""
track = Track.objects.get(pk=id)
track.banter = ""
try:
get(os.getenv('AI_ENDPOINT'))
except ConnectionError:
track.banter = "Error"
track.banter_done = True
track.save()
return f"{track}: AI endpoint not available."
try:
r = post(
os.getenv('AI_ENDPOINT'),
json={
'model': os.getenv('AI_MODEL'),
'prompt': str(track)
},
stream=True
)
r.raise_for_status()
for line in r.iter_lines():
body = json.loads(line)
if 'error' in body:
raise Exception(body['error'])
track.banter += body.get('response', '')
if body.get('done', False):
track.banter_done = True
track.save()
return f"{track}: {body['context']}"
except Exception as e:
track.banter_done = True
track.save()
return f"{track}: {e}"
@shared_task
def get_and_dither_image(url, instance, attribute):
"""Expects a url, and a Django model cls with an ImageField attribute"""
print(f"Parsing {url} for {instance} with attribute {attribute}")
r = get(url)
i = Image.open(BytesIO(r.content))
i = i.resize(size=(256, 256)).convert("P").quantize(colors=32)
buffer = BytesIO()
i.save(fp=buffer, format='PNG')
file = InMemoryUploadedFile(ContentFile(buffer.getvalue()), None, instance.name + ".png", "image/png", buffer.tell, "utf-8")
instance.image = file
instance.save()
+103
View File
@@ -0,0 +1,103 @@
{% load static %}
<html>
<head>
{% include "head.html" %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div class="window" id="main-window">
<div class="title-bar main-title-bar">
<div class="title-bar-text">
{% block window-title %}{% endblock %}
</div>
<div class="title-bar-controls">
<button aria-label="Minimize" onclick="resetWindow()"></button>
<button aria-label="Restore" onclick="resetWindow()"></button>
<form action="{% url 'oidc_logout' %}" method="post">
{% csrf_token %}
<button aria-label="Close" onclick="hideWindow()"></button>
</form>
</div>
</div>
<div class="window-body">
<menu role="tablist" hx-boost="true" class="multirows">
<li role="tab" {% if request.path == '/vote/' %}aria-selected="true" {% endif %}>
<a href="{% url 'vote' %}">Stemmen</a>
</li>
<li role="tab" {% if request.path == '/track/' %}aria-selected="true" {% endif %}>
<a href="{% url 'nominate' %}">Nomineren</a>
</li>
<li role="tab" {% if request.path == '/overview/' %}aria-selected="true" {% endif %}>
<a href="{% url 'overview' %}">Overzicht</a>
</li>
<li role="tab" {% if request.path == '/settings/' %}aria-selected="true" {% endif %}>
<a href="{% url 'settings' %}">Instellingen</a>
</li>
</menu>
{% block multi-row %}{% endblock %}
<div class="window" role="tabpanel">
<div class="window-body">
{% if message_type %}
<script>
function hidePopup() {
document.getElementById("info-window").classList.add("hide");
}
</script>
<div class="window popup" id="info-window">
<div class="title-bar">
<div class="title-bar-text">
{% if message_title %}{{message_title}}{% else %}{{message_type | title}}{% endif %}
</div>
<div class="title-bar-controls">
<button aria-label="Close" onclick="hidePopup()"></button>
</div>
</div>
<div class="window-body">
<div class="icon {{message_type}}"></div>
<div class="text">
<div>
{{message}}
</div>
<section class="field-row" style="justify-content: flex-end">
<button onclick="hidePopup()">Ok</button>
</section>
</div>
</div>
</div>
{% endif %}
{% block main-content %}{% endblock %}
</div>
</div>
</div>
{% block status-bar %}{% endblock%}
</div>
{% block clippy %}{% endblock %}
<script>
position.get_pos(); // Gets position from localStorage
clippy = document.getElementById("clippy")
window.addEventListener('load', function () {
interact('.main-title-bar').draggable({
listeners: {
move (event) {
position.x += event.dx
position.y += event.dy
event.target.parentElement.style.transform = `translate(${position.x}px, ${position.y}px)`
if (clippy) document.getElementById("clippy").style.transform = `translate(${position.x}px, ${position.y}px)`
},
}
})
})
function hideWindow() {
document.getElementById("main-window").classList.add("hide");
}
function resetWindow() {
position.reset()
document.getElementById("main-window").style.transform = '';
if (clippy) document.getElementById("clippy").style.transform = '';
}
</script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{% load static %}
{% if body %}
<script>
function closeclippy(e) {
console.log(e)
e.parentElement.classList.add("hide")
}
</script>
<div id="clippy" class="hide">
<div class="body">
<div class="content">{{ body }}</div>
<br /><button onclick="closeclippy(this)">Ok</button>
</div>
{% if img %}
<img src="{% static img %}" />
{% endif %}
</div>
{% endif %}
+66
View File
@@ -0,0 +1,66 @@
{% load static %}
<html>
<head>
{% include "head.html" %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<script>
a = new Audio("{% static 'startup.wav' %}");
a.volume = 0.5;
a.play();
</script>
<div class="window popup login" id="info-window">
<div class="title-bar">
<div class="title-bar-text">Welkom bij Muzak</div>
<div class="title-bar-controls">
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body">
<div class="icon login"></div>
<div class="text">
<div>
<p>Gebruik onderstaande knop om in te loggen.</p>
<p>Als je geen inloggegevens hebt, meld je dan bij de beheerder van deze site.</p>
<div class="field-row">
<form action="{% url 'oidc_authentication_init' %}" method="get">
<input type="submit" value="Inloggen" class="default">
</form>
<button onclick="bsod()">Annuleren</button>
<script>
function bsod() {
var css = document.createElement("link")
css.setAttribute("rel", "stylesheet")
css.setAttribute("type", "text/css")
css.setAttribute("href", "{% static 'bsod.css' %}")
document.getElementsByTagName("head")[0].appendChild(css)
}
</script>
</div>
</div>
</div>
</div>
</div>
<div id="bsod">
<h1>Lekkerste recept voor suikerbrood</h1>
<div>* 500 gram bloem</div>
<div>* 1 zakje gist</div>
<div>* 1 theelepel kaneeel</div>
<div>* 3 eetlepels gembersiroop</div>
<div>* 200 ml melk</div>
<div>* 250 gram kandijsuiker</div>
<div>
Neem je bloem en zakje gist en meng dit samen met je kaneel in een kom.
Maak een kuiltje in het midden en schenk er gembersiroop in en lauwwarme, niet te hete, melk.
Meng dit tot deeg, voeg een snufje zout toe en laat dat 15 minuten staan.
Kneed het deeg tot een bal en laat het een uur rijzen onder een vochtige theedoek.
Maak van het gerezen deeg een dikke lap en verdeel er je suiker over.
Rol het geheel weer op en doe het over de lengte in een ingeboterde en ingesuikerde cakevorm.
Laat het nog een uurtje rijzen.
Bak het in ongeveer 30 minuten in een voorverwarmde oven op 200 graden.
Voilà. Suikerbrood. Eet smakelijk.
</div>
<div class="continue">Druk op F5 om verder te gaan..</div>
</div>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
{% load static %}
<div id="embed"></div>
<div id="album">
{% if track.album.image %}
<img src="{{track.album.image.url}}" />
{% elif track.album.image_url %}
<img src="{{track.album.image_url}}" />
{% else %}
<img src="https://kagi.com/proxy/windows-95-logo-png-1.png?c=xjdP8sWN58YWflc6NcGN5ZvzREJyOwtrou-TSsaXxcx37J0VVJbSeycOcAptMJEUpzzvqmbKQ3JRuSmPN8HCK43OxjkRe_cQvncxVzlCblc%3D" />
{% endif %}
<h1 title="{{track.name}}">{{track.name}}</h1>
<h2 title="{{track.artist}}">{{track.artist}}</h2>
</div>
<input id="seek" type="range" min="0" step="0.1" max="100" value="0" />
<div id="media-control" class="waiting">
<button id="play"></button>
<button id="pause"></button>
<button id="stop"></button>
<div class="spacer"></div>
<button id="prev" class="dummy"></button>
<button id="back" class="dummy"></button>
<button id="forward" class="dummy"></button>
<button id="next" class="dummy"></button>
<div class="spacer"></div>
<button id="eject" class="dummy"></button>
<div class="spacer"></div>
<input id="volume" type="range" min="1" max="11" value="5" />
</div>
<audio id="kankerherrie" loop src="{% static 'kankerherrie.mp3' %}"></audio>
<script src="https://open.spotify.com/embed/iframe-api/v1"></script>
<script>
window.onSpotifyIframeApiReady = doSpotify;
doSpotify();
function doSpotify (IFrameAPI) {
if (IFrameAPI) {
ifa = IFrameAPI;
} else if (!ifa) return;
let element = document.getElementById('embed');
let playing = false;
let playingPosition = 0;
let duration = {{track.duration_ms}};
let options = {
uri: 'spotify:track:{{track.id}}',
height: 152
};
const callback = (EmbedController) => {
document.getElementById("play").addEventListener('click', () => {
EmbedController.play();
playing = true;
if (playingPosition) {
setTimeout(() => {
EmbedController.seek(parseInt(playingPosition/1000));
playingPosition = 0
}, 100)
}
});
function pause() {
if (playing) {
EmbedController.togglePlay();
playing = false;
}
}
document.getElementById("pause").addEventListener('click', pause);
document.getElementById("stop").addEventListener('click', () => {
setTimeout(() => {
playingPosition = 0;
EmbedController.seek(0);
document.getElementById("seek").value = 0;
}, 500);
pause();
});
EmbedController.addListener('ready', e => {
document.getElementById('media-control').classList.remove('waiting')
document.getElementById("seek").addEventListener('change', (e) => {
let percentile = parseFloat(e.target.value)/100;
EmbedController.seek(duration/1000*percentile);
});
});
EmbedController.addListener('playback_update', e => {
duration = e.data.duration;
if (playingPosition == 0) document.getElementById('seek').value = `${parseInt(e.data.position) / parseInt(e.data.duration) * 100}`;
if (e.data.isPaused) {
playingPosition = e.data.position;
}
else if (playingPosition != 0 && e.data.position != 0) {
EmbedController.seek(parseInt(playingPosition/1000));
playingPosition = 0;
}
})
};
ifa.createController(element, options, callback);
iframe = document.getElementsByTagName("iframe")[0]
iframe.addEventListener('load', (e) => {
console.log(e);
iframe.classList.add('hide');
});
};
document.getElementById("volume").addEventListener('input', (event) => {
let kankerherrie = document.getElementById("kankerherrie")
console.log(event);
let volume = event.target.value / 11;
event.target.classList.add("playing");
event.target.classList.remove("muted");
kankerherrie.play();
if (volume <= 1/11) {
kankerherrie.pause();
event.target.classList.remove("playing");
event.target.classList.add("muted");
}
kankerherrie.volume = volume;
})
</script>

Some files were not shown because too many files have changed in this diff Show More