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
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
while true; do
sleep 30m
cp db.sqlite3 db.sqlite3.$(date +'%Y%m%d%H%M')
ls
done
+11
View File
@@ -0,0 +1,11 @@
SPOTIFY_ENCODED_ID=<Spotify-API-Token>
OIDC_RP_CLIENT_ID=<OIDC-Client-ID>
OIDC_RP_CLIENT_SECRET=<OIDC-Client-Secret>
OIDC_OP_AUTHORIZATION_ENDPOINT=https://auth.yourwebsite.com/application/o/authorize/
OIDC_OP_TOKEN_ENDPOINT=https://auth.yourwebsite.com/application/o/token/
OIDC_OP_USER_ENDPOINT=https://auth.yourwebsite.com/application/o/userinfo/
AI_ENDPOINT=http://localhost:11434/api/generate
AI_MODEL=myModel
DATE_OPEN=<ISO-formatted date>
DATE_NOM_END=<ISO-formatted date>
DATE_VOTE_END=<ISO-formatted date>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'muzak.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for muzak project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'muzak.settings')
application = get_asgi_application()
+11
View File
@@ -0,0 +1,11 @@
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'muzak.settings')
app = Celery('muzak')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
+117
View File
@@ -0,0 +1,117 @@
from pathlib import Path
import os
import dotenv
dotenv.load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'oefjei1lwe918Alkfwuf3ionwu-@kt)6m1e)ah$&^_i9y!qffhm-a$#m6+++'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['*']
CURRENT_HOST = 'http://127.0.0.1:8000' # Needs to be set for spotify callback to work.
INSTALLED_APPS = [
'playlist.apps.PlaylistConfig',
'django.contrib.admin',
'django.contrib.auth',
'mozilla_django_oidc',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_htmx',
'rest_framework'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
]
ROOT_URLCONF = 'muzak.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'muzak.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
AUTHENTICATION_BACKENDS = (
'mozilla_django_oidc.auth.OIDCAuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
)
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'mozilla_django_oidc.contrib.drf.OIDCAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
]
}
OIDC_RP_CLIENT_ID = os.getenv('OIDC_RP_CLIENT_ID')
OIDC_RP_CLIENT_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET')
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv('OIDC_OP_AUTHORIZATION_ENDPOINT')
OIDC_OP_TOKEN_ENDPOINT = os.getenv('OIDC_OP_TOKEN_ENDPOINT')
OIDC_OP_USER_ENDPOINT = os.getenv('OIDC_OP_USER_ENDPOINT')
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
LOGIN_URL = "/"
CELERY_BROKER_URL = 'amqp://localhost:5672'
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
SESSION_COOKIE_AGE = 60*60*24*4
+24
View File
@@ -0,0 +1,24 @@
"""
URL configuration for muzak project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('oidc/', include('mozilla_django_oidc.urls')),
path('', include('playlist.urls')),
]
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for muzak project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'muzak.settings')
application = get_wsgi_application()
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

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