Initial commit
This commit is contained in:
commit
8c3eec4ca1
39 changed files with 3936 additions and 0 deletions
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir -p ~/docker-compose/forgejo)",
|
||||||
|
"Read(//home/marwin/**)",
|
||||||
|
"Read(//etc/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
SECRET_KEY=change-me
|
||||||
|
DEBUG=True
|
||||||
|
AMAZON_AFFILIATE_TAG=diora-20
|
||||||
|
LASTFM_API_KEY=
|
||||||
|
LASTFM_API_SECRET=
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.sqlite3
|
||||||
|
media/
|
||||||
|
staticfiles/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
75
CLAUDE.md
Normal file
75
CLAUDE.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Diora is a Django-based internet radio player with user accounts, track history, Last.fm scrobbling, and PWA support. No build tools or bundlers — pure Django with vanilla JavaScript.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # then edit with your values
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Run
|
||||||
|
python manage.py runserver
|
||||||
|
|
||||||
|
# Database
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no test suite configured. Django's built-in test runner would be used if tests are added: `python manage.py test`.
|
||||||
|
|
||||||
|
## Environment Variables (`.env`)
|
||||||
|
|
||||||
|
```
|
||||||
|
SECRET_KEY=change-me
|
||||||
|
DEBUG=True
|
||||||
|
AMAZON_AFFILIATE_TAG=diora-20
|
||||||
|
LASTFM_API_KEY=
|
||||||
|
LASTFM_API_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two Django apps:
|
||||||
|
|
||||||
|
**`radio/`** — Core player functionality
|
||||||
|
- `models.py`: `SavedStation`, `StationPlay`, `TrackHistory`, `FocusSession`
|
||||||
|
- `icy.py`: Parses ICY metadata from streaming HTTP responses (extracts track titles from radio streams)
|
||||||
|
- `lastfm.py`: Last.fm API wrapper (scrobbling, track love/unlove)
|
||||||
|
- `views.py`: Player page, SSE endpoint for real-time metadata, track recording, station CRUD
|
||||||
|
|
||||||
|
**`accounts/`** — Authentication and user profiles
|
||||||
|
- `models.py`: `UserProfile` (extends Django's `User` via signal; stores Last.fm session key, background image, scrobble preference)
|
||||||
|
- `views.py`: Registration, login, Last.fm OAuth flow, background image upload
|
||||||
|
|
||||||
|
**Frontend (`static/js/`)**
|
||||||
|
- `app.js`: All client-side logic — HTML5 Audio playback, SSE connection for live metadata, station management UI, focus timer
|
||||||
|
- `sw.js`: Service worker for PWA installability
|
||||||
|
- No build step — files are served directly
|
||||||
|
|
||||||
|
**Templates (`templates/`)**
|
||||||
|
- `base.html`: Main layout with navbar
|
||||||
|
- `radio/player.html`: Single-page player UI
|
||||||
|
- `accounts/`: Auth and settings pages
|
||||||
|
|
||||||
|
## Key Data Flows
|
||||||
|
|
||||||
|
**ICY Metadata (real-time track info):**
|
||||||
|
Browser → `GET /radio/sse/?url=<stream_url>` → Django SSE view → `icy.py` opens stream → yields `StreamingHttpResponse` events → `app.js` `EventSource` updates UI
|
||||||
|
|
||||||
|
**Last.fm Scrobbling:**
|
||||||
|
Track recorded via `POST /radio/record/` → `TrackHistory` created → `lastfm.py` scrobbles if user has session key and opt-in enabled
|
||||||
|
|
||||||
|
**Station Persistence:**
|
||||||
|
All station CRUD is JSON API (save/remove/favorite/notes) consumed by `app.js` — no page reloads
|
||||||
|
|
||||||
|
## Static Files
|
||||||
|
|
||||||
|
WhiteNoise serves static files with `CompressedManifestStaticFilesStorage` (cache-busting hashes). Run `python manage.py collectstatic` before production deployment.
|
||||||
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
27
accounts/migrations/0001_initial.py
Normal file
27
accounts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 4.2.29 on 2026-03-15 20:16
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('lastfm_session_key', models.CharField(blank=True, max_length=100)),
|
||||||
|
('lastfm_username', models.CharField(blank=True, max_length=100)),
|
||||||
|
('lastfm_scrobble', models.BooleanField(default=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
19
accounts/migrations/0002_userprofile_background_image.py
Normal file
19
accounts/migrations/0002_userprofile_background_image.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 4.2.29 on 2026-03-15 21:37
|
||||||
|
|
||||||
|
import accounts.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='background_image',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to=accounts.models._bg_upload_path),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
35
accounts/models.py
Normal file
35
accounts/models.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
|
||||||
|
def _bg_upload_path(instance, filename):
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower()
|
||||||
|
return f'backgrounds/bg_{instance.user_id}.{ext}'
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||||
|
lastfm_session_key = models.CharField(max_length=100, blank=True)
|
||||||
|
lastfm_username = models.CharField(max_length=100, blank=True)
|
||||||
|
lastfm_scrobble = models.BooleanField(default=True)
|
||||||
|
background_image = models.FileField(upload_to=_bg_upload_path, null=True, blank=True)
|
||||||
|
|
||||||
|
def has_lastfm(self) -> bool:
|
||||||
|
return bool(self.lastfm_session_key)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Profile of {self.user.username}"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
UserProfile.objects.create(user=instance)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
|
if hasattr(instance, 'profile'):
|
||||||
|
instance.profile.save()
|
||||||
15
accounts/urls.py
Normal file
15
accounts/urls.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from django.contrib.auth.views import LogoutView
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('register/', views.register, name='register'),
|
||||||
|
path('login/', views.login_view, name='login'),
|
||||||
|
path('logout/', LogoutView.as_view(), name='logout'),
|
||||||
|
path('settings/', views.settings_view, name='settings'),
|
||||||
|
path('lastfm/connect/', views.lastfm_connect, name='lastfm_connect'),
|
||||||
|
path('lastfm/callback/', views.lastfm_callback, name='lastfm_callback'),
|
||||||
|
path('lastfm/disconnect/', views.lastfm_disconnect, name='lastfm_disconnect'),
|
||||||
|
path('background/upload/', views.upload_background, name='upload_background'),
|
||||||
|
path('background/delete/', views.delete_background, name='delete_background'),
|
||||||
|
]
|
||||||
164
accounts/views.py
Normal file
164
accounts/views.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import authenticate, login, get_user_model
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from radio import lastfm as lastfm_module
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Register
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('index')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = UserCreationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
login(request, user)
|
||||||
|
return redirect('index')
|
||||||
|
else:
|
||||||
|
form = UserCreationForm()
|
||||||
|
|
||||||
|
return render(request, 'accounts/register.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def login_view(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('index')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AuthenticationForm(data=request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.get_user()
|
||||||
|
login(request, user)
|
||||||
|
next_url = request.GET.get('next', '/')
|
||||||
|
return redirect(next_url)
|
||||||
|
else:
|
||||||
|
form = AuthenticationForm()
|
||||||
|
|
||||||
|
return render(request, 'accounts/login.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_view(request):
|
||||||
|
profile = request.user.profile
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
profile.lastfm_scrobble = 'lastfm_scrobble' in request.POST
|
||||||
|
profile.save(update_fields=['lastfm_scrobble'])
|
||||||
|
return redirect('settings')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'profile': profile,
|
||||||
|
'has_lastfm': profile.has_lastfm(),
|
||||||
|
}
|
||||||
|
return render(request, 'accounts/settings.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Last.fm OAuth
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def lastfm_connect(request):
|
||||||
|
callback_url = request.build_absolute_uri('/accounts/lastfm/callback/')
|
||||||
|
try:
|
||||||
|
auth_url, token = lastfm_module.get_auth_url(callback_url)
|
||||||
|
request.session['lastfm_token'] = token
|
||||||
|
return redirect(auth_url)
|
||||||
|
except Exception as exc:
|
||||||
|
return render(request, 'accounts/settings.html', {
|
||||||
|
'profile': request.user.profile,
|
||||||
|
'has_lastfm': request.user.profile.has_lastfm(),
|
||||||
|
'lastfm_error': f"Could not connect to Last.fm: {exc}",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def lastfm_callback(request):
|
||||||
|
token = request.session.get('lastfm_token')
|
||||||
|
if not token:
|
||||||
|
return redirect('settings')
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_key = lastfm_module.get_session_key(token)
|
||||||
|
username = lastfm_module.get_username(session_key)
|
||||||
|
|
||||||
|
profile = request.user.profile
|
||||||
|
profile.lastfm_session_key = session_key
|
||||||
|
profile.lastfm_username = username
|
||||||
|
profile.save(update_fields=['lastfm_session_key', 'lastfm_username'])
|
||||||
|
|
||||||
|
# Clean up session
|
||||||
|
del request.session['lastfm_token']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return redirect('settings')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def upload_background(request):
|
||||||
|
f = request.FILES.get('file')
|
||||||
|
if not f:
|
||||||
|
return JsonResponse({'error': 'no file'}, status=400)
|
||||||
|
|
||||||
|
ext = f.name.rsplit('.', 1)[-1].lower() if '.' in f.name else ''
|
||||||
|
if ext not in ('jpg', 'jpeg', 'png', 'webp'):
|
||||||
|
return JsonResponse({'error': 'only jpg, png, or webp allowed'}, status=400)
|
||||||
|
|
||||||
|
if f.size > settings.BG_MAX_BYTES:
|
||||||
|
return JsonResponse({'error': 'file too large (max 5 MB)'}, status=400)
|
||||||
|
|
||||||
|
profile = request.user.profile
|
||||||
|
# Delete old file from disk before replacing
|
||||||
|
if profile.background_image:
|
||||||
|
old_path = profile.background_image.path
|
||||||
|
if os.path.exists(old_path):
|
||||||
|
os.remove(old_path)
|
||||||
|
|
||||||
|
profile.background_image = f
|
||||||
|
profile.save(update_fields=['background_image'])
|
||||||
|
return JsonResponse({'ok': True, 'url': profile.background_image.url})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def delete_background(request):
|
||||||
|
profile = request.user.profile
|
||||||
|
if profile.background_image:
|
||||||
|
path = profile.background_image.path
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
profile.background_image = None
|
||||||
|
profile.save(update_fields=['background_image'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def lastfm_disconnect(request):
|
||||||
|
profile = request.user.profile
|
||||||
|
profile.lastfm_session_key = ''
|
||||||
|
profile.lastfm_username = ''
|
||||||
|
profile.save(update_fields=['lastfm_session_key', 'lastfm_username'])
|
||||||
|
return redirect('settings')
|
||||||
0
diora/__init__.py
Normal file
0
diora/__init__.py
Normal file
98
diora/settings.py
Normal file
98
diora/settings.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load .env file from the project root
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
load_dotenv(BASE_DIR / '.env')
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'insecure-default-key-change-in-production')
|
||||||
|
|
||||||
|
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost 127.0.0.1').split()
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'radio',
|
||||||
|
'accounts',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
'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',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'diora.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'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 = 'diora.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'},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
|
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
LOGIN_URL = '/accounts/login/'
|
||||||
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
LOGOUT_REDIRECT_URL = '/'
|
||||||
|
|
||||||
|
# Last.fm
|
||||||
|
LASTFM_API_KEY = os.environ.get('LASTFM_API_KEY', '')
|
||||||
|
LASTFM_API_SECRET = os.environ.get('LASTFM_API_SECRET', '')
|
||||||
|
|
||||||
|
# Amazon affiliate
|
||||||
|
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
||||||
10
diora/urls.py
Normal file
10
diora/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('accounts/', include('accounts.urls')),
|
||||||
|
path('', include('radio.urls')),
|
||||||
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
7
diora/wsgi.py
Normal file
7
diora/wsgi.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'diora.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
22
manage.py
Normal file
22
manage.py
Normal 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', 'diora.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()
|
||||||
0
radio/__init__.py
Normal file
0
radio/__init__.py
Normal file
163
radio/icy.py
Normal file
163
radio/icy.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
ICY stream metadata reader.
|
||||||
|
|
||||||
|
Connects to an internet radio stream with ICY metadata enabled and extracts
|
||||||
|
the StreamTitle tags embedded in the audio data at regular intervals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(url: str, timeout: int):
|
||||||
|
"""Open a raw socket to the stream host, handling http and https."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
host = parsed.hostname
|
||||||
|
port = parsed.port
|
||||||
|
|
||||||
|
if port is None:
|
||||||
|
port = 443 if scheme == 'https' else 80
|
||||||
|
|
||||||
|
path = parsed.path or '/'
|
||||||
|
if parsed.query:
|
||||||
|
path = f"{path}?{parsed.query}"
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
sock.connect((host, port))
|
||||||
|
|
||||||
|
if scheme == 'https':
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
sock = ctx.wrap_socket(sock, server_hostname=host)
|
||||||
|
|
||||||
|
request = (
|
||||||
|
f"GET {path} HTTP/1.0\r\n"
|
||||||
|
f"Host: {host}\r\n"
|
||||||
|
f"User-Agent: diora/1.0\r\n"
|
||||||
|
f"Accept: */*\r\n"
|
||||||
|
f"Icy-MetaData: 1\r\n"
|
||||||
|
f"Connection: close\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
)
|
||||||
|
sock.sendall(request.encode('utf-8'))
|
||||||
|
return sock
|
||||||
|
|
||||||
|
|
||||||
|
def _read_headers(sock) -> dict:
|
||||||
|
"""Read HTTP/ICY response headers and return them as a lowercase dict."""
|
||||||
|
raw = b''
|
||||||
|
while b'\r\n\r\n' not in raw:
|
||||||
|
chunk = sock.recv(1)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
raw += chunk
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
lines = raw.decode('utf-8', errors='replace').split('\r\n')
|
||||||
|
for line in lines[1:]:
|
||||||
|
if ':' in line:
|
||||||
|
key, _, value = line.partition(':')
|
||||||
|
headers[key.strip().lower()] = value.strip()
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _recv_exactly(sock, n: int) -> bytes:
|
||||||
|
"""Read exactly n bytes from socket."""
|
||||||
|
buf = b''
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = sock.recv(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("Stream closed before reading enough bytes")
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(metadata: bytes) -> Optional[str]:
|
||||||
|
"""Extract StreamTitle value from ICY metadata bytes."""
|
||||||
|
text = metadata.decode('utf-8', errors='replace')
|
||||||
|
match = re.search(r"StreamTitle='([^']*)'", text)
|
||||||
|
if match:
|
||||||
|
title = match.group(1).strip()
|
||||||
|
return title if title else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_icy_title(url: str, timeout: int = 10) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Connect to an ICY stream, read one metadata block, and return the StreamTitle.
|
||||||
|
|
||||||
|
Returns the title string or None if it cannot be determined or an error occurs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sock = _connect(url, timeout)
|
||||||
|
try:
|
||||||
|
headers = _read_headers(sock)
|
||||||
|
metaint_str = headers.get('icy-metaint', '')
|
||||||
|
if not metaint_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metaint = int(metaint_str)
|
||||||
|
|
||||||
|
# Discard audio data up to the first metadata block
|
||||||
|
_recv_exactly(sock, metaint)
|
||||||
|
|
||||||
|
# Read metadata length byte (actual length = byte_value * 16)
|
||||||
|
meta_len_byte = _recv_exactly(sock, 1)[0]
|
||||||
|
meta_len = meta_len_byte * 16
|
||||||
|
|
||||||
|
if meta_len == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = _recv_exactly(sock, meta_len)
|
||||||
|
return _extract_title(metadata)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stream_icy_metadata(url: str):
|
||||||
|
"""
|
||||||
|
Generator that continuously yields StreamTitle values from an ICY stream.
|
||||||
|
|
||||||
|
Connects to the stream, reads audio/metadata chunks in a loop, and yields
|
||||||
|
each non-empty StreamTitle as it arrives. Stops on any socket error or
|
||||||
|
server disconnect.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sock = _connect(url, timeout=15)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = _read_headers(sock)
|
||||||
|
metaint_str = headers.get('icy-metaint', '')
|
||||||
|
if not metaint_str:
|
||||||
|
return
|
||||||
|
|
||||||
|
metaint = int(metaint_str)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Discard audio data
|
||||||
|
_recv_exactly(sock, metaint)
|
||||||
|
|
||||||
|
# Read metadata length byte
|
||||||
|
meta_len_byte = _recv_exactly(sock, 1)[0]
|
||||||
|
meta_len = meta_len_byte * 16
|
||||||
|
|
||||||
|
if meta_len > 0:
|
||||||
|
metadata = _recv_exactly(sock, meta_len)
|
||||||
|
title = _extract_title(metadata)
|
||||||
|
if title:
|
||||||
|
yield title
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
70
radio/lastfm.py
Normal file
70
radio/lastfm.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""
|
||||||
|
Last.fm integration using pylast.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_network() -> pylast.LastFMNetwork:
|
||||||
|
"""Return a base LastFMNetwork (no session key attached)."""
|
||||||
|
return pylast.LastFMNetwork(
|
||||||
|
api_key=settings.LASTFM_API_KEY,
|
||||||
|
api_secret=settings.LASTFM_API_SECRET,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_url(callback_url: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Start the Last.fm web auth flow.
|
||||||
|
|
||||||
|
Returns (auth_url, token). The caller should redirect the user to auth_url
|
||||||
|
and store the token in the session for use in the callback.
|
||||||
|
"""
|
||||||
|
network = get_network()
|
||||||
|
sg = pylast.SessionKeyGenerator(network)
|
||||||
|
token = sg.get_web_auth_token()
|
||||||
|
url = sg.get_web_auth_url(token)
|
||||||
|
return url, token
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_key(token: str) -> str:
|
||||||
|
"""
|
||||||
|
Exchange an authorized token for a permanent session key.
|
||||||
|
|
||||||
|
Must be called after the user has visited the auth URL and clicked Allow.
|
||||||
|
"""
|
||||||
|
network = get_network()
|
||||||
|
sg = pylast.SessionKeyGenerator(network)
|
||||||
|
return sg.get_web_auth_session_key(token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_username(session_key: str) -> str:
|
||||||
|
"""Return the Last.fm username for the given session key."""
|
||||||
|
network = get_network()
|
||||||
|
network.session_key = session_key
|
||||||
|
user = network.get_authenticated_user()
|
||||||
|
return user.name
|
||||||
|
|
||||||
|
|
||||||
|
def scrobble(session_key: str, artist: str, title: str, timestamp: int) -> None:
|
||||||
|
"""
|
||||||
|
Scrobble a track to Last.fm.
|
||||||
|
|
||||||
|
timestamp should be a Unix epoch integer (seconds since 1970-01-01 UTC).
|
||||||
|
"""
|
||||||
|
network = get_network()
|
||||||
|
network.session_key = session_key
|
||||||
|
network.scrobble(artist=artist, title=title, timestamp=timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_track(raw: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Parse a raw 'Artist - Title' string into (artist, title).
|
||||||
|
|
||||||
|
Falls back to ('', raw) when no ' - ' separator is found.
|
||||||
|
"""
|
||||||
|
if ' - ' in raw:
|
||||||
|
parts = raw.split(' - ', 1)
|
||||||
|
return parts[0].strip(), parts[1].strip()
|
||||||
|
return '', raw.strip()
|
||||||
51
radio/migrations/0001_initial.py
Normal file
51
radio/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Generated by Django 4.2.29 on 2026-03-15 20:16
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TrackHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('session_key', models.CharField(blank=True, max_length=40)),
|
||||||
|
('station_name', models.CharField(max_length=200)),
|
||||||
|
('track', models.CharField(max_length=500)),
|
||||||
|
('played_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('scrobbled', models.BooleanField(default=False)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='track_history', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-played_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SavedStation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('url', models.URLField(max_length=500)),
|
||||||
|
('bitrate', models.CharField(blank=True, max_length=20)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100)),
|
||||||
|
('tags', models.CharField(blank=True, max_length=200)),
|
||||||
|
('favicon_url', models.URLField(blank=True, max_length=500)),
|
||||||
|
('is_favorite', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_stations', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-is_favorite', 'name'],
|
||||||
|
'unique_together': {('user', 'url')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
33
radio/migrations/0002_stationplay.py
Normal file
33
radio/migrations/0002_stationplay.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 4.2.29 on 2026-03-15 20:36
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('radio', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StationPlay',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('station_name', models.CharField(max_length=200)),
|
||||||
|
('station_url', models.URLField(max_length=500)),
|
||||||
|
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('ended_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('hour_of_day', models.IntegerField()),
|
||||||
|
('day_of_week', models.IntegerField()),
|
||||||
|
('is_weekend', models.BooleanField()),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='station_plays', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-started_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
radio/migrations/0003_savedstation_notes.py
Normal file
18
radio/migrations/0003_savedstation_notes.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.29 on 2026-03-15 21:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('radio', '0002_stationplay'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='savedstation',
|
||||||
|
name='notes',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
29
radio/migrations/0004_focussession.py
Normal file
29
radio/migrations/0004_focussession.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 4.2.29 on 2026-03-15 21:24
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('radio', '0003_savedstation_notes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FocusSession',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('station_name', models.CharField(blank=True, max_length=200)),
|
||||||
|
('completed_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('duration_minutes', models.IntegerField(default=25)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='focus_sessions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-completed_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
radio/migrations/__init__.py
Normal file
0
radio/migrations/__init__.py
Normal file
74
radio/models.py
Normal file
74
radio/models.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class SavedStation(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='saved_stations')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
url = models.URLField(max_length=500)
|
||||||
|
bitrate = models.CharField(max_length=20, blank=True)
|
||||||
|
country = models.CharField(max_length=100, blank=True)
|
||||||
|
tags = models.CharField(max_length=200, blank=True)
|
||||||
|
favicon_url = models.URLField(max_length=500, blank=True)
|
||||||
|
is_favorite = models.BooleanField(default=False)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'url')
|
||||||
|
ordering = ['-is_favorite', 'name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.user.username})"
|
||||||
|
|
||||||
|
|
||||||
|
class StationPlay(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='station_plays')
|
||||||
|
station_name = models.CharField(max_length=200)
|
||||||
|
station_url = models.URLField(max_length=500)
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
ended_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
hour_of_day = models.IntegerField() # 0–23, set on save
|
||||||
|
day_of_week = models.IntegerField() # 0=Mon … 6=Sun, set on save
|
||||||
|
is_weekend = models.BooleanField() # True if day_of_week >= 5
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-started_at']
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
from django.utils import timezone
|
||||||
|
now = timezone.now()
|
||||||
|
self.hour_of_day = now.hour
|
||||||
|
self.day_of_week = now.weekday()
|
||||||
|
self.is_weekend = self.day_of_week >= 5
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.station_name} by {self.user.username} at {self.started_at}"
|
||||||
|
|
||||||
|
|
||||||
|
class FocusSession(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='focus_sessions')
|
||||||
|
station_name = models.CharField(max_length=200, blank=True)
|
||||||
|
completed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
duration_minutes = models.IntegerField(default=25)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-completed_at']
|
||||||
|
|
||||||
|
|
||||||
|
class TrackHistory(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User, null=True, blank=True, on_delete=models.SET_NULL, related_name='track_history'
|
||||||
|
)
|
||||||
|
session_key = models.CharField(max_length=40, blank=True) # for anonymous users
|
||||||
|
station_name = models.CharField(max_length=200)
|
||||||
|
track = models.CharField(max_length=500)
|
||||||
|
played_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
scrobbled = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-played_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.track} @ {self.station_name}"
|
||||||
20
radio/urls.py
Normal file
20
radio/urls.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.index, name='index'),
|
||||||
|
path('radio/sse/', views.sse_metadata, name='sse_metadata'),
|
||||||
|
path('radio/record/', views.record_track, name='record_track'),
|
||||||
|
path('radio/affiliate/', views.affiliate_links, name='affiliate_links'),
|
||||||
|
path('radio/save/', views.save_station, name='save_station'),
|
||||||
|
path('radio/remove/<int:pk>/', views.remove_station, name='remove_station'),
|
||||||
|
path('radio/favorite/<int:pk>/', views.toggle_favorite, name='toggle_favorite'),
|
||||||
|
path('radio/history/', views.history_json, name='history_json'),
|
||||||
|
path('radio/play/start/', views.start_play, name='start_play'),
|
||||||
|
path('radio/play/stop/', views.stop_play, name='stop_play'),
|
||||||
|
path('radio/recommendations/', views.recommendations, name='recommendations'),
|
||||||
|
path('radio/import/', views.import_m3u, name='import_m3u'),
|
||||||
|
path('radio/notes/<int:pk>/', views.save_station_notes, name='save_station_notes'),
|
||||||
|
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
|
||||||
|
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
|
||||||
|
]
|
||||||
512
radio/views.py
Normal file
512
radio/views.py
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import (
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseBadRequest,
|
||||||
|
JsonResponse,
|
||||||
|
StreamingHttpResponse,
|
||||||
|
)
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from .icy import stream_icy_metadata
|
||||||
|
from . import lastfm as lastfm_module
|
||||||
|
from .models import SavedStation, StationPlay, TrackHistory, FocusSession
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Index / player
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def index(request):
|
||||||
|
saved_stations = []
|
||||||
|
history = []
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
saved_stations = list(
|
||||||
|
request.user.saved_stations.values(
|
||||||
|
'id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
history = list(
|
||||||
|
request.user.track_history.values(
|
||||||
|
'station_name', 'track', 'played_at', 'scrobbled'
|
||||||
|
)[:50]
|
||||||
|
)
|
||||||
|
# Convert datetime to ISO string for JSON serialisation in template
|
||||||
|
for entry in history:
|
||||||
|
entry['played_at'] = entry['played_at'].isoformat()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'saved_stations': saved_stations,
|
||||||
|
'history': history,
|
||||||
|
}
|
||||||
|
return render(request, 'radio/player.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSE metadata stream
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def sse_metadata(request):
|
||||||
|
url = request.GET.get('url', '').strip()
|
||||||
|
if not url:
|
||||||
|
return HttpResponseBadRequest('url parameter required')
|
||||||
|
|
||||||
|
def event_stream():
|
||||||
|
last_title = None
|
||||||
|
try:
|
||||||
|
for title in stream_icy_metadata(url):
|
||||||
|
if title != last_title:
|
||||||
|
last_title = title
|
||||||
|
yield f"data: {json.dumps({'track': title})}\n\n"
|
||||||
|
except Exception:
|
||||||
|
yield f"data: {json.dumps({'error': 'stream ended'})}\n\n"
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(event_stream(), content_type='text/event-stream')
|
||||||
|
response['Cache-Control'] = 'no-cache'
|
||||||
|
response['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Record track
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def record_track(request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
station_name = body.get('station_name', '').strip()
|
||||||
|
track = body.get('track', '').strip()
|
||||||
|
do_scrobble = body.get('scrobble', False)
|
||||||
|
|
||||||
|
if not station_name or not track:
|
||||||
|
return JsonResponse({'error': 'station_name and track required'}, status=400)
|
||||||
|
|
||||||
|
# Ensure session exists for anonymous users
|
||||||
|
if not request.session.session_key:
|
||||||
|
request.session.create()
|
||||||
|
|
||||||
|
history_entry = TrackHistory(
|
||||||
|
station_name=station_name,
|
||||||
|
track=track,
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
history_entry.user = request.user
|
||||||
|
else:
|
||||||
|
history_entry.session_key = request.session.session_key
|
||||||
|
|
||||||
|
# Attempt scrobble before saving (so we can mark it)
|
||||||
|
scrobbled = False
|
||||||
|
if (
|
||||||
|
do_scrobble
|
||||||
|
and request.user.is_authenticated
|
||||||
|
and hasattr(request.user, 'profile')
|
||||||
|
and request.user.profile.has_lastfm()
|
||||||
|
and request.user.profile.lastfm_scrobble
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
artist, title = lastfm_module.parse_track(track)
|
||||||
|
if not artist:
|
||||||
|
artist = station_name
|
||||||
|
lastfm_module.scrobble(
|
||||||
|
session_key=request.user.profile.lastfm_session_key,
|
||||||
|
artist=artist,
|
||||||
|
title=title,
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
)
|
||||||
|
scrobbled = True
|
||||||
|
except Exception:
|
||||||
|
pass # Scrobble failure is non-fatal
|
||||||
|
|
||||||
|
history_entry.scrobbled = scrobbled
|
||||||
|
history_entry.save()
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True, 'scrobbled': scrobbled})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Affiliate links
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def affiliate_links(request):
|
||||||
|
track = request.GET.get('track', '').strip()
|
||||||
|
if not track:
|
||||||
|
return JsonResponse({'error': 'track parameter required'}, status=400)
|
||||||
|
|
||||||
|
itunes_data = {}
|
||||||
|
try:
|
||||||
|
itunes_url = (
|
||||||
|
f"https://itunes.apple.com/search"
|
||||||
|
f"?term={urllib.parse.quote(track)}&media=music&limit=1"
|
||||||
|
)
|
||||||
|
resp = requests.get(itunes_url, timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
results = resp.json().get('results', [])
|
||||||
|
if results:
|
||||||
|
r = results[0]
|
||||||
|
itunes_data = {
|
||||||
|
'name': r.get('trackName', ''),
|
||||||
|
'artist': r.get('artistName', ''),
|
||||||
|
'album': r.get('collectionName', ''),
|
||||||
|
'artwork': r.get('artworkUrl100', ''),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
amazon_url = (
|
||||||
|
f"https://www.amazon.com/s"
|
||||||
|
f"?k={urllib.parse.quote(track)}"
|
||||||
|
f"&i=digital-music"
|
||||||
|
f"&tag={settings.AMAZON_AFFILIATE_TAG}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({'amazon_url': amazon_url, 'itunes_data': itunes_data})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save station
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def save_station(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
name = body.get('name', '').strip()
|
||||||
|
url = body.get('url', '').strip()
|
||||||
|
if not name or not url:
|
||||||
|
return JsonResponse({'error': 'name and url required'}, status=400)
|
||||||
|
|
||||||
|
station, created = SavedStation.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
url=url,
|
||||||
|
defaults={
|
||||||
|
'name': name,
|
||||||
|
'bitrate': body.get('bitrate', ''),
|
||||||
|
'country': body.get('country', ''),
|
||||||
|
'tags': body.get('tags', ''),
|
||||||
|
'favicon_url': body.get('favicon_url', ''),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
# Update mutable fields in case station details changed
|
||||||
|
station.name = name
|
||||||
|
station.bitrate = body.get('bitrate', station.bitrate)
|
||||||
|
station.country = body.get('country', station.country)
|
||||||
|
station.tags = body.get('tags', station.tags)
|
||||||
|
station.favicon_url = body.get('favicon_url', station.favicon_url)
|
||||||
|
station.save()
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True, 'id': station.id, 'created': created})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Remove station
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def remove_station(request, pk):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
station = SavedStation.objects.get(pk=pk, user=request.user)
|
||||||
|
station.delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
except SavedStation.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Toggle favorite
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def toggle_favorite(request, pk):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
station = SavedStation.objects.get(pk=pk, user=request.user)
|
||||||
|
station.is_favorite = not station.is_favorite
|
||||||
|
station.save(update_fields=['is_favorite'])
|
||||||
|
return JsonResponse({'ok': True, 'is_favorite': station.is_favorite})
|
||||||
|
except SavedStation.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Station play tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def start_play(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
station_name = body.get('station_name', '').strip()
|
||||||
|
station_url = body.get('station_url', '').strip()
|
||||||
|
|
||||||
|
if not station_name or not station_url:
|
||||||
|
return JsonResponse({'error': 'station_name and station_url required'}, status=400)
|
||||||
|
|
||||||
|
play = StationPlay(
|
||||||
|
user=request.user,
|
||||||
|
station_name=station_name,
|
||||||
|
station_url=station_url,
|
||||||
|
)
|
||||||
|
play.save()
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True, 'play_id': play.id})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def stop_play(request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
play_id = body.get('play_id')
|
||||||
|
if play_id is None:
|
||||||
|
return JsonResponse({'error': 'play_id required'}, status=400)
|
||||||
|
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
play = StationPlay.objects.get(id=play_id, user=request.user)
|
||||||
|
except StationPlay.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
play.ended_at = timezone.now()
|
||||||
|
play.save(update_fields=['ended_at'])
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Recommendations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _time_context_label(hour, is_weekend):
|
||||||
|
prefix = 'weekend' if is_weekend else 'weekday'
|
||||||
|
if 5 <= hour <= 11:
|
||||||
|
period = 'morning'
|
||||||
|
elif 12 <= hour <= 16:
|
||||||
|
period = 'afternoon'
|
||||||
|
elif 17 <= hour <= 21:
|
||||||
|
period = 'evening'
|
||||||
|
else:
|
||||||
|
period = 'night'
|
||||||
|
return f'{prefix} {period}'
|
||||||
|
|
||||||
|
|
||||||
|
def recommendations(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'recommendations': [], 'context': ''})
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
current_hour = now.hour
|
||||||
|
current_is_weekend = now.weekday() >= 5
|
||||||
|
|
||||||
|
# Build ±2 hour window (wrapping around midnight)
|
||||||
|
hour_window = [(current_hour + i) % 24 for i in range(-2, 3)]
|
||||||
|
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
plays_qs = (
|
||||||
|
StationPlay.objects
|
||||||
|
.filter(
|
||||||
|
user=request.user,
|
||||||
|
hour_of_day__in=hour_window,
|
||||||
|
is_weekend=current_is_weekend,
|
||||||
|
)
|
||||||
|
.values('station_url', 'station_name')
|
||||||
|
.annotate(play_count=Count('id'))
|
||||||
|
.order_by('-play_count')[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build a lookup of saved stations by URL
|
||||||
|
saved_by_url = {
|
||||||
|
s['url']: s
|
||||||
|
for s in request.user.saved_stations.values('id', 'name', 'url', 'favicon_url')
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for entry in plays_qs:
|
||||||
|
saved = saved_by_url.get(entry['station_url'])
|
||||||
|
results.append({
|
||||||
|
'station_name': saved['name'] if saved else entry['station_name'],
|
||||||
|
'station_url': entry['station_url'],
|
||||||
|
'play_count': entry['play_count'],
|
||||||
|
'saved_id': saved['id'] if saved else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
context_label = _time_context_label(current_hour, current_is_weekend)
|
||||||
|
|
||||||
|
return JsonResponse({'recommendations': results, 'context': context_label})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# History JSON
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def history_json(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
entries = list(
|
||||||
|
request.user.track_history.values(
|
||||||
|
'station_name', 'track', 'played_at', 'scrobbled'
|
||||||
|
)[:200]
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
entry['played_at'] = entry['played_at'].isoformat()
|
||||||
|
|
||||||
|
return JsonResponse(entries, safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Station notes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def save_station_notes(request, pk):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
try:
|
||||||
|
station = SavedStation.objects.get(pk=pk, user=request.user)
|
||||||
|
except SavedStation.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
station.notes = body.get('notes', '').strip()
|
||||||
|
station.save()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Focus session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def record_focus_session(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
session = FocusSession.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
station_name=body.get('station_name', ''),
|
||||||
|
duration_minutes=body.get('duration_minutes', 25),
|
||||||
|
)
|
||||||
|
return JsonResponse({'ok': True, 'id': session.id})
|
||||||
|
|
||||||
|
|
||||||
|
def focus_stats(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'today_sessions': 0, 'today_minutes': 0, 'sessions': []})
|
||||||
|
from django.utils.timezone import now, localtime
|
||||||
|
from django.db.models import Sum
|
||||||
|
today = localtime(now()).date()
|
||||||
|
qs = request.user.focus_sessions.all()
|
||||||
|
today_qs = qs.filter(completed_at__date=today)
|
||||||
|
today_count = today_qs.count()
|
||||||
|
today_minutes = today_qs.aggregate(t=Sum('duration_minutes'))['t'] or 0
|
||||||
|
recent = list(qs.values('station_name', 'completed_at', 'duration_minutes')[:50])
|
||||||
|
for s in recent:
|
||||||
|
s['completed_at'] = s['completed_at'].isoformat()
|
||||||
|
return JsonResponse({
|
||||||
|
'today_sessions': today_count,
|
||||||
|
'today_minutes': today_minutes,
|
||||||
|
'sessions': recent,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M3U import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def import_m3u(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
f = request.FILES.get('file')
|
||||||
|
if not f:
|
||||||
|
return JsonResponse({'error': 'no file uploaded'}, status=400)
|
||||||
|
|
||||||
|
if not f.name.lower().endswith(('.m3u', '.m3u8')):
|
||||||
|
return JsonResponse({'error': 'file must be .m3u or .m3u8'}, status=400)
|
||||||
|
|
||||||
|
content = f.read().decode('utf-8', errors='replace')
|
||||||
|
lines = content.splitlines()
|
||||||
|
|
||||||
|
stations = []
|
||||||
|
pending_name = None
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('#EXTINF'):
|
||||||
|
comma = line.find(',')
|
||||||
|
pending_name = line[comma + 1:].strip() if comma != -1 else None
|
||||||
|
elif line and not line.startswith('#'):
|
||||||
|
parsed = urllib.parse.urlparse(line)
|
||||||
|
name = pending_name or parsed.netloc or line
|
||||||
|
stations.append({'name': name, 'url': line})
|
||||||
|
pending_name = None
|
||||||
|
|
||||||
|
if not stations:
|
||||||
|
return JsonResponse({'error': 'no stations found in file'}, status=400)
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
for s in stations:
|
||||||
|
_, created = SavedStation.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
url=s['url'],
|
||||||
|
defaults={'name': s['name']},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
django>=4.2
|
||||||
|
pylast>=5.2
|
||||||
|
requests>=2.31
|
||||||
|
python-dotenv>=1.0
|
||||||
|
whitenoise>=6.6
|
||||||
897
static/css/app.css
Normal file
897
static/css/app.css
Normal file
|
|
@ -0,0 +1,897 @@
|
||||||
|
/* =========================================================
|
||||||
|
diora — dark radio player theme
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #000;
|
||||||
|
--bg-card: transparent;
|
||||||
|
--bg-alt: transparent;
|
||||||
|
--bg-row: transparent;
|
||||||
|
--bg-row-alt: transparent;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--accent: #e63946;
|
||||||
|
--accent-hover: #ff4d58;
|
||||||
|
--green: #2ecc71;
|
||||||
|
--yellow: #f1c40f;
|
||||||
|
--font: 'Courier New', Courier, monospace;
|
||||||
|
--radius: 3px;
|
||||||
|
--nav-h: 48px;
|
||||||
|
--bar-h: 72px;
|
||||||
|
|
||||||
|
/* contrast scheme — default: white on dark */
|
||||||
|
--fg: #fff;
|
||||||
|
--fg-muted: #fff;
|
||||||
|
--outline: #000;
|
||||||
|
--text: var(--fg);
|
||||||
|
--text-muted: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* inverted: black on bright */
|
||||||
|
body.bright-bg {
|
||||||
|
--fg: #000;
|
||||||
|
--fg-muted: #000;
|
||||||
|
--outline: #fff;
|
||||||
|
--border: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-shadow:
|
||||||
|
-1px -1px 0 var(--outline),
|
||||||
|
1px -1px 0 var(--outline),
|
||||||
|
-1px 1px 0 var(--outline),
|
||||||
|
1px 1px 0 var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elements with their own coloured backgrounds — no outline, no override */
|
||||||
|
.btn-play,
|
||||||
|
.btn-amazon,
|
||||||
|
.btn-lastfm,
|
||||||
|
.btn-danger,
|
||||||
|
.btn-primary,
|
||||||
|
.navbar-brand {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent colour stays red regardless of scheme */
|
||||||
|
.navbar-brand { color: var(--accent); }
|
||||||
|
.tab-btn.active { color: var(--accent); }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
NAVBAR
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--nav-h);
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.navbar-brand:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
MAIN CONTENT
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.5rem calc(var(--bar-h) + 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
MESSAGES
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border-left: 3px solid var(--text-muted);
|
||||||
|
}
|
||||||
|
.message-error { border-color: var(--accent); }
|
||||||
|
.message-success { border-color: var(--green); }
|
||||||
|
.message-warning { border-color: var(--yellow); }
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
NOW PLAYING BAR (fixed at bottom)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.now-playing-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--bar-h);
|
||||||
|
background: transparent;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
z-index: 200;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-station {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-track {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 80px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
AFFILIATE SECTION
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.affiliate-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-artwork {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-track {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-artist {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-album {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-amazon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: #ff9900;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.btn-amazon:hover {
|
||||||
|
background: #ffad33;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
TABS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
SEARCH
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 1.4em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
TABLES
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:nth-child(odd) { background: transparent; }
|
||||||
|
.data-table tbody tr:nth-child(even) { background: transparent; }
|
||||||
|
.data-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); }
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
BUTTONS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
.btn-play:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.btn-play.playing {
|
||||||
|
background: #333;
|
||||||
|
border-color: #555;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
color: var(--yellow);
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
.btn-save:hover {
|
||||||
|
background: #2a2a00;
|
||||||
|
border-color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #300;
|
||||||
|
border-color: #700;
|
||||||
|
color: #faa;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #500;
|
||||||
|
border-color: #a00;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lastfm {
|
||||||
|
background: #d51007;
|
||||||
|
border-color: #d51007;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.btn-lastfm:hover {
|
||||||
|
background: #ff1a0e;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.btn-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn-icon.active {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
AUTH FORMS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input[type="text"],
|
||||||
|
.auth-form input[type="password"],
|
||||||
|
.auth-form input[type="email"] {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.auth-form input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-errors {
|
||||||
|
list-style: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-errors {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: #1a0000;
|
||||||
|
border: 1px solid #500;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-switch {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
SETTINGS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastfm-description {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-status {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
RESPONSIVE
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
RECOMMENDATIONS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.recommendations-section {
|
||||||
|
padding: 12px 16px 4px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
.recommendations-context {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.recommendations-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.recommendations-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 0.75rem 0.75rem calc(var(--bar-h) + 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-info {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affiliate-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th:nth-child(3),
|
||||||
|
.data-table td:nth-child(3),
|
||||||
|
.data-table th:nth-child(4),
|
||||||
|
.data-table td:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-bar {
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
TIMER WIDGET
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.timer-widget {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-phase {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--fg);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
DO NOT DISTURB / FOCUS MODE
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
body.dnd-mode .navbar,
|
||||||
|
body.dnd-mode .tabs,
|
||||||
|
body.dnd-mode .tab-panel,
|
||||||
|
body.dnd-mode .affiliate-section {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode .now-playing-bar {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode.dnd-dark .now-playing-bar {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode .dnd-only {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode .now-playing-info {
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode .now-playing-station {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode .now-playing-track {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dnd-mode .timer-display {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
MOOD CHIPS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.mood-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-chip {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #ccc;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-chip:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-today {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--fg);
|
||||||
|
margin-left: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curated-lists-container {
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curated-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curated-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--fg);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curated-stations {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curated-stations li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curated-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 548 B |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
1034
static/js/app.js
Normal file
1034
static/js/app.js
Normal file
File diff suppressed because it is too large
Load diff
67
static/js/sw.js
Normal file
67
static/js/sw.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* diora service worker — caches the app shell for offline use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE = 'diora-v1';
|
||||||
|
const SHELL = [
|
||||||
|
'/',
|
||||||
|
'/static/css/app.css',
|
||||||
|
'/static/js/app.js',
|
||||||
|
'/static/manifest.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: pre-cache the app shell
|
||||||
|
self.addEventListener('install', function (event) {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then(function (cache) {
|
||||||
|
return cache.addAll(SHELL);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Activate immediately without waiting for old tabs to close
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate: remove stale caches from previous versions
|
||||||
|
self.addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(function (keys) {
|
||||||
|
return Promise.all(
|
||||||
|
keys.filter(function (key) { return key !== CACHE; })
|
||||||
|
.map(function (key) { return caches.delete(key); })
|
||||||
|
);
|
||||||
|
}).then(function () {
|
||||||
|
return self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch: serve from cache, fall back to network
|
||||||
|
self.addEventListener('fetch', function (event) {
|
||||||
|
// Only handle GET requests; let POST/SSE etc. pass through
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
// Don't intercept SSE or API requests
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.pathname.startsWith('/radio/sse/') ||
|
||||||
|
url.pathname.startsWith('/radio/record/') ||
|
||||||
|
url.pathname.startsWith('/radio/affiliate/') ||
|
||||||
|
url.pathname.startsWith('/admin/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(function (cached) {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(event.request).then(function (response) {
|
||||||
|
// Cache successful GET responses for shell assets
|
||||||
|
if (response && response.status === 200 && response.type === 'basic') {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE).then(function (cache) {
|
||||||
|
cache.put(event.request, clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
24
static/manifest.json
Normal file
24
static/manifest.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "diora",
|
||||||
|
"short_name": "diora",
|
||||||
|
"description": "Internet radio player",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
templates/accounts/login.html
Normal file
35
templates/accounts/login.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login — diora{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-container">
|
||||||
|
<h1 class="auth-title">Login</h1>
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<ul class="field-errors">
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="form-errors">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-switch">
|
||||||
|
Don't have an account? <a href="{% url 'register' %}">Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
38
templates/accounts/register.html
Normal file
38
templates/accounts/register.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Register — diora{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-container">
|
||||||
|
<h1 class="auth-title">Create Account</h1>
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<small class="field-help">{{ field.help_text }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<ul class="field-errors">
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="form-errors">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-switch">
|
||||||
|
Already have an account? <a href="{% url 'login' %}">Login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
101
templates/accounts/settings.html
Normal file
101
templates/accounts/settings.html
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings — diora{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-container">
|
||||||
|
<h1 class="settings-title">Settings</h1>
|
||||||
|
|
||||||
|
{% if lastfm_error %}
|
||||||
|
<div class="message message-error">{{ lastfm_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last.fm section -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Last.fm</h2>
|
||||||
|
{% if has_lastfm %}
|
||||||
|
<div class="lastfm-connected">
|
||||||
|
<p class="connected-status">
|
||||||
|
Connected as <strong>{{ profile.lastfm_username }}</strong>
|
||||||
|
</p>
|
||||||
|
<form method="post" class="settings-form" id="scrobble-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="lastfm_scrobble" id="lastfm_scrobble"
|
||||||
|
{% if profile.lastfm_scrobble %}checked{% endif %}
|
||||||
|
onchange="document.getElementById('scrobble-form').submit()">
|
||||||
|
Scrobble tracks to Last.fm
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'lastfm_disconnect' %}" class="inline-form" style="margin-top: 1rem;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Disconnect Last.fm</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="lastfm-description">
|
||||||
|
Connect your Last.fm account to automatically scrobble the tracks you listen to.
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Connect Last.fm</a>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Background section -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Background</h2>
|
||||||
|
{% if request.user.profile.background_image %}
|
||||||
|
<p>
|
||||||
|
<img src="{{ request.user.profile.background_image.url }}" class="bg-preview" alt="Your background">
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Remove background</button>
|
||||||
|
</form>
|
||||||
|
<p style="margin-top:12px;">Upload a new image to replace it:</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB).</p>
|
||||||
|
{% endif %}
|
||||||
|
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
|
||||||
|
<label class="btn" for="bg-upload-input">Choose file</label>
|
||||||
|
<input type="file" id="bg-upload-input" accept=".jpg,.jpeg,.png,.webp" style="display:none;" onchange="uploadBackground(this)">
|
||||||
|
<span id="bg-upload-status" style="font-size:0.85rem; color:#888;"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Account section -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn">Logout</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<style>
|
||||||
|
.bg-preview { max-width: 240px; max-height: 135px; object-fit: cover; border-radius: 4px; border: 1px solid #333; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function getCsrfToken() {
|
||||||
|
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
||||||
|
}
|
||||||
|
async function uploadBackground(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const status = document.getElementById('bg-upload-status');
|
||||||
|
status.textContent = 'Uploading…';
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('csrfmiddlewaretoken', getCsrfToken());
|
||||||
|
try {
|
||||||
|
const res = await fetch('/accounts/background/upload/', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) { location.reload(); }
|
||||||
|
else { status.textContent = data.error || 'Upload failed'; }
|
||||||
|
} catch (e) { status.textContent = 'Upload failed'; }
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
59
templates/base.html
Normal file
59
templates/base.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="diora">
|
||||||
|
<meta name="description" content="Internet radio player">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<title>{% block title %}diora{% endblock %}</title>
|
||||||
|
{% if user.is_authenticated and user.profile.background_image %}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-image: url('{{ user.profile.background_image.url }}');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
</head>
|
||||||
|
<body{% if user.is_authenticated and user.profile.background_image %} data-bg="{{ user.profile.background_image.url }}"{% endif %}>
|
||||||
|
<nav class="navbar">
|
||||||
|
<a href="/" class="navbar-brand">diora</a>
|
||||||
|
<div class="navbar-links">
|
||||||
|
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Toggle contrast mode">◑</button>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<span class="navbar-user">{{ user.username }}</span>
|
||||||
|
<a href="{% url 'settings' %}">Settings</a>
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn-link">Logout</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'login' %}">Login</a>
|
||||||
|
<a href="{% url 'register' %}">Register</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
{% if messages %}
|
||||||
|
<div class="messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message message-{{ message.tags }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
189
templates/radio/player.html
Normal file
189
templates/radio/player.html
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}diora — radio player{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- ===== NOW PLAYING BAR ===== -->
|
||||||
|
<section class="now-playing-bar" id="now-playing-bar">
|
||||||
|
<div class="now-playing-info">
|
||||||
|
<span class="now-playing-station" id="now-playing-station">— no station —</span>
|
||||||
|
<span class="now-playing-track" id="now-playing-track"></span>
|
||||||
|
</div>
|
||||||
|
<div class="now-playing-controls">
|
||||||
|
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()">▶ Play</button>
|
||||||
|
<label class="volume-label">
|
||||||
|
<span>vol</span>
|
||||||
|
<input type="range" id="volume" min="0" max="100" value="80" class="volume-slider">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||||
|
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⛶</button>
|
||||||
|
</div>
|
||||||
|
<div class="timer-widget" id="timer-widget">
|
||||||
|
<span class="timer-phase" id="timer-phase-label">focus</span>
|
||||||
|
<span class="timer-display" id="timer-display">25:00</span>
|
||||||
|
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Start/pause timer">▶</button>
|
||||||
|
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Reset timer">↺</button>
|
||||||
|
<span class="focus-today" id="focus-today-widget" style="display:none;"></span>
|
||||||
|
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background">💡</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== AFFILIATE / TRACK INFO ===== -->
|
||||||
|
<section class="affiliate-section" id="affiliate-section" style="display:none;">
|
||||||
|
<img class="affiliate-artwork" id="affiliate-artwork" src="" alt="Album art">
|
||||||
|
<div class="affiliate-info">
|
||||||
|
<div class="affiliate-track" id="affiliate-track-name"></div>
|
||||||
|
<div class="affiliate-artist" id="affiliate-artist-name"></div>
|
||||||
|
<div class="affiliate-album" id="affiliate-album-name"></div>
|
||||||
|
<a class="btn btn-amazon" id="affiliate-amazon-link" href="#" target="_blank" rel="noopener noreferrer">
|
||||||
|
Buy on Amazon Music
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== TABS ===== -->
|
||||||
|
<div class="tabs" id="tabs">
|
||||||
|
<button class="tab-btn active" onclick="showTab('search')">Search</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('saved')">Saved</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('history')">History</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('focus')">Focus</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SEARCH TAB ===== -->
|
||||||
|
<section class="tab-panel" id="tab-search">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
||||||
|
<button class="btn" onclick="doSearch()">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="mood-chips" id="mood-chips"></div>
|
||||||
|
<div id="curated-lists" class="curated-lists-container"></div>
|
||||||
|
<div id="search-status" class="status-msg"></div>
|
||||||
|
<table class="data-table" id="search-results-table" style="display:none;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Bitrate</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Tags</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="search-results-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== SAVED TAB ===== -->
|
||||||
|
<section class="tab-panel" id="tab-saved" style="display:none;">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div id="recommendations" class="recommendations-section">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="import-bar">
|
||||||
|
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
|
||||||
|
⇧ Import M3U
|
||||||
|
</label>
|
||||||
|
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
|
||||||
|
<span id="import-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="data-table" id="saved-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>★</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Bitrate</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th title="Notes">✎</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="saved-tbody">
|
||||||
|
{% for station in saved_stations %}
|
||||||
|
<tr id="saved-row-{{ station.id }}" data-id="{{ station.id }}" data-url="{{ station.url }}" data-name="{{ station.name }}">
|
||||||
|
<td>
|
||||||
|
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
|
||||||
|
onclick="toggleFav({{ station.id }})"
|
||||||
|
title="Toggle favorite">★</button>
|
||||||
|
</td>
|
||||||
|
<td class="station-name-cell">{{ station.name }}</td>
|
||||||
|
<td>{{ station.bitrate }}</td>
|
||||||
|
<td>{{ station.country }}</td>
|
||||||
|
<td class="notes-cell" onclick="editNotes({{ station.id }}, this.textContent.trim())" title="{{ station.notes|default:'' }}" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ station.notes }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm"
|
||||||
|
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
onclick="removeStation({{ station.id }})">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="auth-prompt">
|
||||||
|
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||||
|
to save stations and sync across devices.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== HISTORY TAB ===== -->
|
||||||
|
<section class="tab-panel" id="tab-history" style="display:none;">
|
||||||
|
<table class="data-table" id="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Station</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>♬</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-tbody">
|
||||||
|
{% for entry in history %}
|
||||||
|
<tr>
|
||||||
|
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
|
||||||
|
<td>{{ entry.station_name }}</td>
|
||||||
|
<td>{{ entry.track }}</td>
|
||||||
|
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</span>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr id="history-empty-row"><td colspan="4" class="empty-msg">No history yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== FOCUS TAB ===== -->
|
||||||
|
<section class="tab-panel" id="tab-focus" style="display:none;">
|
||||||
|
<table class="data-table" id="focus-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Station</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="focus-tbody">
|
||||||
|
<tr><td colspan="3" class="empty-msg">No focus sessions yet. Start the timer!</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Pass Django context into JS
|
||||||
|
const INITIAL_SAVED = {{ saved_stations|safe }};
|
||||||
|
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Reference in a new issue