Initial commit

This commit is contained in:
marwin 2026-03-16 19:19:22 +01:00
commit 8c3eec4ca1
39 changed files with 3936 additions and 0 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(mkdir -p ~/docker-compose/forgejo)",
"Read(//home/marwin/**)",
"Read(//etc/**)"
]
}
}

5
.env.example Normal file
View 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
View 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
View 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
View file

View 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)),
],
),
]

View 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),
),
]

View file

35
accounts/models.py Normal file
View 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
View 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
View 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
View file

98
diora/settings.py Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '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
View file

163
radio/icy.py Normal file
View 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
View 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()

View 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')},
},
),
]

View 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'],
},
),
]

View 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),
),
]

View 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'],
},
),
]

View file

74
radio/models.py Normal file
View 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() # 023, 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

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

File diff suppressed because it is too large Load diff

67
static/js/sw.js Normal file
View 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
View 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"
}
]
}

View 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 %}

View 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 %}

View 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
View 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
View 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()">&#9654; 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()">&#9733; 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">
&#8679; 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>&#9733;</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">&#9733;</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 }})">
&#9654; 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>&#9836;</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">&#10003;</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 %}