Compare commits

..

No commits in common. "2bd83f631560677091464d30a2a6438569376740" and "0d5ab9a1779e98f5ca8f879b74e3d7326706e882" have entirely different histories.

41 changed files with 171 additions and 5188 deletions

View file

@ -1,18 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends dcron && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Write cron job: refresh podcast feeds every hour
RUN echo "0 * * * * root cd /app && python manage.py refresh_feeds >> /var/log/cron.log 2>&1" \
> /etc/cron.d/podcast-refresh && \
chmod 0644 /etc/cron.d/podcast-refresh && \
touch /var/log/cron.log
CMD ["dcron", "-f"]

View file

@ -1,38 +0,0 @@
# Generated by Django 6.0.3 on 2026-03-19 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_userprofile_background_image'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='background_encrypted',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='userprofile',
name='background_iv',
field=models.CharField(blank=True, max_length=32),
),
migrations.AddField(
model_name='userprofile',
name='background_mime',
field=models.CharField(blank=True, max_length=30),
),
migrations.AddField(
model_name='userprofile',
name='focus_station_name',
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name='userprofile',
name='focus_station_url',
field=models.URLField(blank=True, max_length=1000),
),
]

View file

@ -9,12 +9,7 @@ class UserProfile(models.Model):
lastfm_session_key = models.CharField(max_length=100, blank=True) lastfm_session_key = models.CharField(max_length=100, blank=True)
lastfm_username = models.CharField(max_length=100, blank=True) lastfm_username = models.CharField(max_length=100, blank=True)
lastfm_scrobble = models.BooleanField(default=True) lastfm_scrobble = models.BooleanField(default=True)
background_image_data = models.TextField(blank=True) # base64 data URL (legacy) background_image_data = models.TextField(blank=True) # base64 data URL
background_encrypted = models.TextField(blank=True) # base64 AES-GCM ciphertext
background_iv = models.CharField(max_length=32, blank=True) # hex IV
background_mime = models.CharField(max_length=30, blank=True) # e.g. 'image/jpeg'
focus_station_url = models.URLField(max_length=1000, blank=True)
focus_station_name = models.CharField(max_length=300, blank=True)
def has_lastfm(self) -> bool: def has_lastfm(self) -> bool:
return bool(self.lastfm_session_key) return bool(self.lastfm_session_key)

View file

@ -12,5 +12,4 @@ urlpatterns = [
path('lastfm/disconnect/', views.lastfm_disconnect, name='lastfm_disconnect'), path('lastfm/disconnect/', views.lastfm_disconnect, name='lastfm_disconnect'),
path('background/upload/', views.upload_background, name='upload_background'), path('background/upload/', views.upload_background, name='upload_background'),
path('background/delete/', views.delete_background, name='delete_background'), path('background/delete/', views.delete_background, name='delete_background'),
path('focus-station/', views.save_focus_station, name='save_focus_station'),
] ]

View file

@ -1,12 +1,10 @@
import base64 import base64
import json
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate, login, get_user_model from django.contrib.auth import authenticate, login, get_user_model
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module from radio import lastfm as lastfm_module
@ -118,36 +116,27 @@ def lastfm_callback(request):
@login_required @login_required
@csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def upload_background(request): def upload_background(request):
try: f = request.FILES.get('file')
body = json.loads(request.body) if not f:
except (json.JSONDecodeError, ValueError): return JsonResponse({'error': 'no file'}, status=400)
return JsonResponse({'error': 'invalid JSON'}, status=400)
iv = body.get('iv', '').strip() ext = f.name.rsplit('.', 1)[-1].lower() if '.' in f.name else ''
ciphertext = body.get('ciphertext', '').strip() mime_map = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'webp': 'image/webp'}
mime_type = body.get('mime_type', '').strip() if ext not in mime_map:
file_size = int(body.get('file_size', 0)) return JsonResponse({'error': 'only jpg, png, or webp allowed'}, status=400)
if not all([iv, ciphertext, mime_type]): if f.size > settings.BG_MAX_BYTES:
return JsonResponse({'error': 'iv, ciphertext, mime_type required'}, status=400)
allowed_mimes = {'image/jpeg', 'image/png', 'image/webp'}
if mime_type not in allowed_mimes:
return JsonResponse({'error': 'only jpeg, png, or webp allowed'}, status=400)
if file_size > settings.BG_MAX_BYTES:
return JsonResponse({'error': 'file too large (max 5 MB)'}, status=400) return JsonResponse({'error': 'file too large (max 5 MB)'}, status=400)
data = base64.b64encode(f.read()).decode('ascii')
data_url = f"data:{mime_map[ext]};base64,{data}"
profile = request.user.profile profile = request.user.profile
profile.background_image_data = '' profile.background_image_data = data_url
profile.background_encrypted = ciphertext profile.save(update_fields=['background_image_data'])
profile.background_iv = iv return JsonResponse({'ok': True, 'url': data_url})
profile.background_mime = mime_type
profile.save(update_fields=['background_image_data', 'background_encrypted', 'background_iv', 'background_mime'])
return JsonResponse({'ok': True})
@login_required @login_required
@ -155,32 +144,10 @@ def upload_background(request):
def delete_background(request): def delete_background(request):
profile = request.user.profile profile = request.user.profile
profile.background_image_data = '' profile.background_image_data = ''
profile.background_encrypted = '' profile.save(update_fields=['background_image_data'])
profile.background_iv = ''
profile.background_mime = ''
profile.save(update_fields=['background_image_data', 'background_encrypted', 'background_iv', 'background_mime'])
return redirect('settings') return redirect('settings')
@login_required
@csrf_exempt
@require_http_methods(['POST'])
def save_focus_station(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
url = body.get('url', '').strip()
name = body.get('name', '').strip()
profile = request.user.profile
profile.focus_station_url = url
profile.focus_station_name = name
profile.save(update_fields=['focus_station_url', 'focus_station_name'])
return JsonResponse({'ok': True})
@login_required @login_required
@require_http_methods(['POST']) @require_http_methods(['POST'])
def lastfm_disconnect(request): def lastfm_disconnect(request):

View file

View file

@ -1,5 +0,0 @@
from django.contrib import admin
from .models import EBook, EBookProgress
admin.site.register(EBook)
admin.site.register(EBookProgress)

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'books'

View file

@ -1,45 +0,0 @@
# Generated by Django 6.0.3 on 2026-03-19 09:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EBook',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('meta_ct', models.TextField()),
('meta_iv', models.CharField(max_length=32)),
('data_ct', models.TextField()),
('data_iv', models.CharField(max_length=32)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['uploaded_at'],
},
),
migrations.CreateModel(
name='EBookProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('scroll_fraction', models.FloatField(default=0.0)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_progress', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'book')},
},
),
]

View file

@ -1,44 +0,0 @@
# Generated by Django 6.0.3 on 2026-03-19 11:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EBookBookmarks',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ct', models.TextField()),
('iv', models.CharField(max_length=32)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_bookmarks', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'book')},
},
),
migrations.CreateModel(
name='EBookHighlights',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ct', models.TextField()),
('iv', models.CharField(max_length=32)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='highlights', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_highlights', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'book')},
},
),
]

View file

@ -1,58 +0,0 @@
from django.db import models
from django.contrib.auth.models import User
class EBook(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebooks')
meta_ct = models.TextField() # base64 AES-GCM ciphertext of {title, author, filename}
meta_iv = models.CharField(max_length=32) # hex IV for metadata
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['uploaded_at']
def __str__(self):
return f"EBook #{self.pk} (user={self.user_id})"
class EBookProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
scroll_fraction = models.FloatField(default=0.0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'book')
def __str__(self):
return f"Progress book={self.book_id} user={self.user_id} {self.scroll_fraction:.2f}"
class EBookHighlights(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_highlights')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='highlights')
ct = models.TextField() # base64 AES-GCM ciphertext of JSON array
iv = models.CharField(max_length=32) # hex IV
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'book')
def __str__(self):
return f"Highlights book={self.book_id} user={self.user_id}"
class EBookBookmarks(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_bookmarks')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='bookmarks')
ct = models.TextField()
iv = models.CharField(max_length=32)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'book')
def __str__(self):
return f"Bookmarks book={self.book_id} user={self.user_id}"

View file

@ -1,12 +0,0 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.book_list, name='book_list'),
path('upload/', views.upload_book, name='upload_book'),
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
]

View file

@ -1,225 +0,0 @@
import base64
import json
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
def _require_auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
@require_http_methods(['GET'])
def book_list(request):
err = _require_auth(request)
if err:
return err
books = list(
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at')
)
for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat()
# Include saved scroll_fraction for each book
progress_map = {
p.book_id: p.scroll_fraction
for p in EBookProgress.objects.filter(user=request.user)
}
for b in books:
b['scroll_fraction'] = progress_map.get(b['id'], 0.0)
return JsonResponse(books, safe=False)
@csrf_exempt
@require_http_methods(['POST'])
def upload_book(request):
err = _require_auth(request)
if err:
return err
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
if not all([meta_ct, meta_iv, data_ct, data_iv]):
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
# Enforce size limit: ciphertext is plaintext + 16-byte GCM tag
max_bytes = getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024) + 32
try:
raw_size = len(base64.b64decode(data_ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
if raw_size > max_bytes:
return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400)
book = EBook.objects.create(
user=request.user,
meta_ct=meta_ct,
meta_iv=meta_iv,
data_ct=data_ct,
data_iv=data_iv,
)
return JsonResponse({'ok': True, 'id': book.id})
@require_http_methods(['GET'])
def get_book_data(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
@csrf_exempt
@require_http_methods(['POST'])
def delete_book(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
book.delete()
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def save_progress(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.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)
scroll_fraction = float(body.get('scroll_fraction', 0.0))
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
progress, _ = EBookProgress.objects.get_or_create(
user=request.user,
book=book,
)
progress.scroll_fraction = scroll_fraction
progress.save(update_fields=['scroll_fraction', 'updated_at'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def book_highlights(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
if request.method == 'GET':
try:
row = EBookHighlights.objects.get(user=request.user, book=book)
return JsonResponse({'ct': row.ct, 'iv': row.iv})
except EBookHighlights.DoesNotExist:
return JsonResponse({'ct': None, 'iv': None})
# POST — upsert
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
ct = body.get('ct', '')
iv = body.get('iv', '')
if not ct or not iv:
return JsonResponse({'error': 'ct and iv required'}, status=400)
# Size guard: highlights ≤ 700 KB base64
try:
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > 700 * 1024:
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
row.ct = ct
row.iv = iv
row.save(update_fields=['ct', 'iv', 'updated_at'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def book_bookmarks(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
if request.method == 'GET':
try:
row = EBookBookmarks.objects.get(user=request.user, book=book)
return JsonResponse({'ct': row.ct, 'iv': row.iv})
except EBookBookmarks.DoesNotExist:
return JsonResponse({'ct': None, 'iv': None})
# POST — upsert
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
ct = body.get('ct', '')
iv = body.get('iv', '')
if not ct or not iv:
return JsonResponse({'error': 'ct and iv required'}, status=400)
# Size guard: bookmarks ≤ 100 KB base64
try:
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > 100 * 1024:
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
row.ct = ct
row.iv = iv
row.save(update_fields=['ct', 'iv', 'updated_at'])
return JsonResponse({'ok': True})

View file

@ -1,11 +1,7 @@
import mimetypes
import os import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
mimetypes.add_type('application/javascript', '.js')
mimetypes.add_type('text/css', '.css')
# Load .env file from the project root # Load .env file from the project root
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env') load_dotenv(BASE_DIR / '.env')
@ -27,15 +23,8 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'radio', 'radio',
'accounts', 'accounts',
'podcasts',
'books',
] ]
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~25 MB body
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
@ -71,12 +60,9 @@ DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data' / 'db.sqlite3', 'NAME': BASE_DIR / 'data' / 'db.sqlite3',
'OPTIONS': {'timeout': 20},
} }
} }
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},

View file

@ -6,7 +6,5 @@ from django.urls import path, include
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')), path('accounts/', include('accounts.urls')),
path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')),
path('', include('radio.urls')), path('', include('radio.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,22 +0,0 @@
services:
web:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
env_file:
- .env
restart: unless-stopped
cron:
build:
context: .
dockerfile: Dockerfile.cron
volumes:
- ./data:/app/data
env_file:
- .env
restart: unless-stopped
depends_on:
- web

View file

View file

@ -1,30 +0,0 @@
from django.contrib import admin
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue
@admin.register(PodcastFeed)
class PodcastFeedAdmin(admin.ModelAdmin):
list_display = ('title', 'user', 'last_refreshed_at', 'added_at')
list_filter = ('user',)
search_fields = ('title', 'rss_url')
readonly_fields = ('added_at', 'last_refreshed_at')
@admin.register(PodcastEpisode)
class PodcastEpisodeAdmin(admin.ModelAdmin):
list_display = ('title', 'feed', 'pub_date', 'duration_seconds')
list_filter = ('feed__user',)
search_fields = ('title', 'guid')
readonly_fields = ('discovered_at',)
@admin.register(EpisodeProgress)
class EpisodeProgressAdmin(admin.ModelAdmin):
list_display = ('user', 'episode', 'position_seconds', 'played', 'updated_at')
list_filter = ('user', 'played')
@admin.register(PodcastQueue)
class PodcastQueueAdmin(admin.ModelAdmin):
list_display = ('user', 'episode', 'position', 'added_at')
list_filter = ('user',)

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class PodcastsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'podcasts'

View file

@ -1,34 +0,0 @@
import sys
from django.core.management.base import BaseCommand
from podcasts.models import PodcastFeed
from podcasts.views import _refresh_feed
class Command(BaseCommand):
help = 'Refresh podcast feeds (fetch new episodes from RSS)'
def add_arguments(self, parser):
parser.add_argument('--feed-id', type=int, help='Refresh only this feed ID')
parser.add_argument('--limit', type=int, default=0, help='Max number of feeds to refresh')
def handle(self, *args, **options):
qs = PodcastFeed.objects.all().order_by('last_refreshed_at')
if options['feed_id']:
qs = qs.filter(pk=options['feed_id'])
if options['limit']:
qs = qs[: options['limit']]
if not qs.exists():
self.stdout.write('No feeds to refresh.')
return
for feed in qs:
try:
new_ep = _refresh_feed(feed)
self.stdout.write(f'{feed.title}: +{new_ep} episodes')
except Exception as e:
self.stderr.write(f'ERROR refreshing "{feed.title}" ({feed.rss_url}): {e}')

View file

@ -1,86 +0,0 @@
# Generated by Django 6.0.3 on 2026-03-19 08:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PodcastFeed',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rss_url', models.URLField(max_length=1000)),
('title', models.CharField(max_length=300)),
('description', models.TextField(blank=True)),
('artwork_url', models.URLField(blank=True, max_length=1000)),
('author', models.CharField(blank=True, max_length=300)),
('link', models.URLField(blank=True, max_length=1000)),
('last_refreshed_at', models.DateTimeField(blank=True, null=True)),
('added_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_feeds', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['title'],
'unique_together': {('user', 'rss_url')},
},
),
migrations.CreateModel(
name='PodcastEpisode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('guid', models.CharField(max_length=1000)),
('title', models.CharField(max_length=500)),
('description', models.TextField(blank=True)),
('audio_url', models.URLField(max_length=1000)),
('duration_seconds', models.IntegerField(default=0)),
('pub_date', models.DateTimeField(blank=True, null=True)),
('episode_number', models.IntegerField(blank=True, null=True)),
('season_number', models.IntegerField(blank=True, null=True)),
('artwork_url', models.URLField(blank=True, max_length=1000)),
('discovered_at', models.DateTimeField(auto_now_add=True)),
('feed', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='podcasts.podcastfeed')),
],
options={
'ordering': ['-pub_date'],
'unique_together': {('feed', 'guid')},
},
),
migrations.CreateModel(
name='EpisodeProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('position_seconds', models.IntegerField(default=0)),
('played', models.BooleanField(default=False)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_progress', to=settings.AUTH_USER_MODEL)),
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='podcasts.podcastepisode')),
],
options={
'ordering': ['-updated_at'],
'unique_together': {('user', 'episode')},
},
),
migrations.CreateModel(
name='PodcastQueue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('position', models.PositiveIntegerField(default=0)),
('added_at', models.DateTimeField(auto_now_add=True)),
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_by', to='podcasts.podcastepisode')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_queue', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['position'],
'unique_together': {('user', 'episode')},
},
),
]

View file

@ -1,71 +0,0 @@
from django.contrib.auth.models import User
from django.db import models
class PodcastFeed(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_feeds')
rss_url = models.URLField(max_length=1000)
title = models.CharField(max_length=300)
description = models.TextField(blank=True)
artwork_url = models.URLField(max_length=1000, blank=True)
author = models.CharField(max_length=300, blank=True)
link = models.URLField(max_length=1000, blank=True)
last_refreshed_at = models.DateTimeField(null=True, blank=True)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'rss_url')
ordering = ['title']
def __str__(self):
return self.title
class PodcastEpisode(models.Model):
feed = models.ForeignKey(PodcastFeed, on_delete=models.CASCADE, related_name='episodes')
guid = models.CharField(max_length=1000)
title = models.CharField(max_length=500)
description = models.TextField(blank=True)
audio_url = models.URLField(max_length=1000)
duration_seconds = models.IntegerField(default=0)
pub_date = models.DateTimeField(null=True, blank=True)
episode_number = models.IntegerField(null=True, blank=True)
season_number = models.IntegerField(null=True, blank=True)
artwork_url = models.URLField(max_length=1000, blank=True)
discovered_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('feed', 'guid')
ordering = ['-pub_date']
def __str__(self):
return self.title
class EpisodeProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_progress')
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress')
position_seconds = models.IntegerField(default=0)
played = models.BooleanField(default=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'episode')
ordering = ['-updated_at']
def __str__(self):
return f'{self.user} - {self.episode}'
class PodcastQueue(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_queue')
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='queued_by')
position = models.PositiveIntegerField(default=0)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'episode')
ordering = ['position']
def __str__(self):
return f'{self.user} queue pos {self.position}: {self.episode}'

View file

@ -1,19 +0,0 @@
from django.urls import path
from . import views
urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'),
path('feeds/', views.feed_list, name='podcast_feed_list'),
path('feeds/add/', views.add_feed, name='podcast_add_feed'),
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'),
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
path('queue/', views.queue_get, name='podcast_queue_get'),
path('queue/add/', views.queue_add, name='podcast_queue_add'),
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'),
path('progress/save/', views.save_progress, name='podcast_save_progress'),
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
path('inbox/', views.inbox, name='podcast_inbox'),
]

View file

@ -1,550 +0,0 @@
import json
import urllib.parse
import xml.etree.ElementTree as ET
import feedparser
import requests
from django.conf import settings
from django.db.models import Count, Q
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue
# ---------------------------------------------------------------------------
# Duration helper
# ---------------------------------------------------------------------------
def _parse_duration(raw):
"""Return duration in seconds from itunes:duration string or int."""
if not raw:
return 0
if isinstance(raw, int):
return raw
raw = str(raw).strip()
parts = raw.split(':')
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
return int(raw)
except (ValueError, TypeError):
return 0
# ---------------------------------------------------------------------------
# Shared feed refresh helper
# ---------------------------------------------------------------------------
def _refresh_feed(feed_obj):
"""Parse rss_url and upsert episodes. Returns count of new episodes."""
parsed = feedparser.parse(feed_obj.rss_url)
channel = parsed.feed
feed_obj.title = channel.get('title', feed_obj.title or feed_obj.rss_url)[:300]
feed_obj.description = channel.get('subtitle', channel.get('description', ''))[:2000]
feed_obj.author = channel.get('author', channel.get('itunes_author', ''))[:300]
feed_obj.link = channel.get('link', '')[:1000]
# Artwork
image = channel.get('image', {})
feed_obj.artwork_url = (
channel.get('itunes_image', {}).get('href', '')
or image.get('href', '')
or image.get('url', '')
)[:1000]
feed_obj.last_refreshed_at = timezone.now()
feed_obj.save()
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
new_count = 0
for entry in parsed.entries[:max_ep]:
# Find audio enclosure
audio_url = ''
for enc in entry.get('enclosures', []):
mime = enc.get('type', '')
if mime.startswith('audio/') or enc.get('url', '').endswith(('.mp3', '.m4a', '.ogg', '.opus', '.aac')):
audio_url = enc.get('url', '')
break
if not audio_url:
continue
guid = entry.get('id') or entry.get('guid') or audio_url
guid = str(guid)[:1000]
title = entry.get('title', 'Untitled')[:500]
description = entry.get('summary', entry.get('description', ''))
duration_raw = entry.get('itunes_duration', 0)
duration_seconds = _parse_duration(duration_raw)
pub_date = None
if entry.get('published_parsed'):
import datetime as dt
t = entry.published_parsed
pub_date = dt.datetime(*t[:6], tzinfo=dt.timezone.utc)
ep_number = None
try:
ep_number = int(entry.get('itunes_episode', ''))
except (ValueError, TypeError):
pass
season_number = None
try:
season_number = int(entry.get('itunes_season', ''))
except (ValueError, TypeError):
pass
artwork_url = entry.get('itunes_image', {}).get('href', '')[:1000]
_, created = PodcastEpisode.objects.get_or_create(
feed=feed_obj,
guid=guid,
defaults={
'title': title,
'description': description,
'audio_url': audio_url[:1000],
'duration_seconds': duration_seconds,
'pub_date': pub_date,
'episode_number': ep_number,
'season_number': season_number,
'artwork_url': artwork_url,
},
)
if created:
new_count += 1
return new_count
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
def podcast_search(request):
q = request.GET.get('q', '').strip()
if not q:
return JsonResponse({'results': []})
try:
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
resp = requests.get(url, timeout=6)
resp.raise_for_status()
raw = resp.json().get('results', [])
except Exception as e:
return JsonResponse({'error': str(e)}, status=502)
results = []
for r in raw:
results.append({
'id': r.get('collectionId'),
'title': r.get('collectionName', ''),
'author': r.get('artistName', ''),
'artwork_url': r.get('artworkUrl100', ''),
'rss_url': r.get('feedUrl', ''),
'genre': r.get('primaryGenreName', ''),
})
return JsonResponse({'results': results})
# ---------------------------------------------------------------------------
# Feed list
# ---------------------------------------------------------------------------
@csrf_exempt
def feed_list(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
feeds = list(
request.user.podcast_feeds
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author')
)
for f in feeds:
if f['last_refreshed_at']:
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
return JsonResponse({'feeds': feeds})
# ---------------------------------------------------------------------------
# Add feed
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def add_feed(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)
rss_url = body.get('rss_url', '').strip()
if not rss_url:
return JsonResponse({'error': 'rss_url required'}, status=400)
feed, created = PodcastFeed.objects.get_or_create(
user=request.user,
rss_url=rss_url,
defaults={'title': body.get('title', rss_url)[:300]},
)
new_episodes = 0
if created:
try:
new_episodes = _refresh_feed(feed)
except Exception as e:
feed.delete()
return JsonResponse({'error': f'Failed to parse feed: {e}'}, status=400)
return JsonResponse({
'ok': True,
'created': created,
'feed_id': feed.id,
'title': feed.title,
'artwork_url': feed.artwork_url,
'new_episodes': new_episodes,
})
# ---------------------------------------------------------------------------
# Remove feed
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def remove_feed(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
feed.delete()
return JsonResponse({'ok': True})
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
# ---------------------------------------------------------------------------
# Feed episodes
# ---------------------------------------------------------------------------
@csrf_exempt
def feed_episodes(request, pk):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
episodes = list(
feed.episodes.values(
'id', 'title', 'description', 'audio_url', 'duration_seconds',
'pub_date', 'artwork_url', 'episode_number', 'season_number',
)
)
# Batch-fetch progress
progress_map = {
ep['episode_id']: ep
for ep in EpisodeProgress.objects.filter(
user=request.user,
episode__feed=feed,
).values('episode_id', 'position_seconds', 'played')
}
# Batch-fetch queue membership
queued_ids = set(
PodcastQueue.objects.filter(
user=request.user,
episode__feed=feed,
).values_list('episode_id', flat=True)
)
for ep in episodes:
if ep['pub_date']:
ep['pub_date'] = ep['pub_date'].isoformat()
prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0)
ep['played'] = prog.get('played', False)
ep['in_queue'] = ep['id'] in queued_ids
return JsonResponse({
'feed': {
'id': feed.id,
'title': feed.title,
'artwork_url': feed.artwork_url,
'author': feed.author,
},
'episodes': episodes,
})
# ---------------------------------------------------------------------------
# Import OPML
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def import_opml(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)
try:
content = f.read().decode('utf-8', errors='replace')
root = ET.fromstring(content)
except Exception as e:
return JsonResponse({'error': f'invalid OPML: {e}'}, status=400)
added = 0
skipped = 0
for outline in root.iter('outline'):
rss_url = outline.get('xmlUrl', '').strip()
if not rss_url:
continue
title = outline.get('title', outline.get('text', rss_url))[:300]
_, created = PodcastFeed.objects.get_or_create(
user=request.user,
rss_url=rss_url,
defaults={'title': title},
)
if created:
added += 1
else:
skipped += 1
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
# ---------------------------------------------------------------------------
# Refresh feed now
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def refresh_feed_now(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)
feed_id = body.get('feed_id')
if not feed_id:
return JsonResponse({'error': 'feed_id required'}, status=400)
try:
feed = PodcastFeed.objects.get(pk=feed_id, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
new_episodes = _refresh_feed(feed)
except Exception as e:
return JsonResponse({'error': str(e)}, status=502)
return JsonResponse({'ok': True, 'new_episodes': new_episodes})
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
@csrf_exempt
def queue_get(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
items = list(
PodcastQueue.objects
.filter(user=request.user)
.select_related('episode__feed')
.values(
'id', 'position',
'episode__id', 'episode__title', 'episode__audio_url',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title',
)
)
return JsonResponse({'queue': items})
@csrf_exempt
@require_http_methods(['POST'])
def queue_add(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)
episode_id = body.get('episode_id')
try:
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
max_pos = PodcastQueue.objects.filter(user=request.user).count()
_, created = PodcastQueue.objects.get_or_create(
user=request.user,
episode=episode,
defaults={'position': max_pos},
)
return JsonResponse({'ok': True, 'created': created})
@csrf_exempt
@require_http_methods(['POST'])
def queue_remove(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)
episode_id = body.get('episode_id')
PodcastQueue.objects.filter(user=request.user, episode_id=episode_id).delete()
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def queue_reorder(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)
order = body.get('order', []) # list of episode ids
for pos, ep_id in enumerate(order):
PodcastQueue.objects.filter(user=request.user, episode_id=ep_id).update(position=pos)
return JsonResponse({'ok': True})
# ---------------------------------------------------------------------------
# Progress
# ---------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def save_progress(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)
episode_id = body.get('episode_id')
position = int(body.get('position_seconds', 0))
try:
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
progress, _ = EpisodeProgress.objects.get_or_create(
user=request.user,
episode=episode,
)
progress.position_seconds = position
# Auto-mark played at 90%
if episode.duration_seconds and episode.duration_seconds > 0:
if position >= episode.duration_seconds * 0.9:
progress.played = True
progress.save()
return JsonResponse({'ok': True, 'played': progress.played})
@csrf_exempt
@require_http_methods(['POST'])
def mark_played(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)
episode_id = body.get('episode_id')
played = bool(body.get('played', True))
try:
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
progress, _ = EpisodeProgress.objects.get_or_create(
user=request.user,
episode=episode,
)
progress.played = played
progress.save(update_fields=['played', 'updated_at'])
return JsonResponse({'ok': True, 'played': played})
# ---------------------------------------------------------------------------
# Inbox
# ---------------------------------------------------------------------------
@csrf_exempt
def inbox(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
played_ids = set(
EpisodeProgress.objects.filter(
user=request.user,
played=True,
).values_list('episode_id', flat=True)
)
episodes = list(
PodcastEpisode.objects.filter(feed__user=request.user)
.exclude(id__in=played_ids)
.select_related('feed')
.order_by('-pub_date')
.values(
'id', 'title', 'audio_url', 'duration_seconds',
'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url',
)[:100]
)
for ep in episodes:
if ep['pub_date']:
ep['pub_date'] = ep['pub_date'].isoformat()
return JsonResponse({'episodes': episodes})

View file

@ -2,7 +2,6 @@ import json
import time import time
import urllib.parse import urllib.parse
from datetime import datetime from datetime import datetime
from django.core.serializers.json import DjangoJSONEncoder
import requests import requests
from django.db.models import Count from django.db.models import Count
@ -61,37 +60,11 @@ def index(request):
) )
) )
initial_podcast_feeds = []
if request.user.is_authenticated:
from podcasts.models import PodcastFeed
initial_podcast_feeds = list(
request.user.podcast_feeds.values('id', 'title', 'artwork_url', 'rss_url')
)
focus_station = None # null in JS means "never configured, use default"
encrypted_bg = {}
if request.user.is_authenticated:
p = getattr(request.user, 'profile', None)
if p:
if p.focus_station_url or p.focus_station_name:
focus_station = {'url': p.focus_station_url, 'name': p.focus_station_name}
if p.background_encrypted:
encrypted_bg = {
'iv': p.background_iv,
'ciphertext': p.background_encrypted,
'mime': p.background_mime,
}
context = { context = {
'saved_stations': saved_stations, 'saved_stations': saved_stations,
'history': history, 'history': history,
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED, 'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
'featured_stations': featured, 'featured_stations': featured,
'initial_podcast_feeds': initial_podcast_feeds,
'focus_station': focus_station,
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
'encrypted_bg': encrypted_bg,
'encrypted_bg_json': json.dumps(encrypted_bg, cls=DjangoJSONEncoder) if encrypted_bg else '',
} }
return render(request, 'radio/player.html', context) return render(request, 'radio/player.html', context)

View file

@ -3,4 +3,3 @@ pylast>=5.2
requests>=2.31 requests>=2.31
python-dotenv>=1.0 python-dotenv>=1.0
whitenoise>=6.6 whitenoise>=6.6
feedparser>=6.0

View file

@ -32,13 +32,8 @@
--text-muted: var(--fg); --text-muted: var(--fg);
} }
/* inverted: black on white */ /* inverted: black on bright */
body.bright-bg { body.bright-bg {
--bg: #fff;
--bg-card: #f5f5f5;
--bg-alt: #f0f0f0;
--bg-row: #fafafa;
--bg-row-alt: #f5f5f5;
--fg: #000; --fg: #000;
--fg-muted: #000; --fg-muted: #000;
--outline: #fff; --outline: #fff;
@ -319,22 +314,6 @@ a:hover {
min-height: 200px; min-height: 200px;
} }
.sub-tabs {
border-bottom-color: transparent;
margin-bottom: 1rem;
}
.sub-tabs .tab-btn {
font-size: 0.8rem;
padding: 0.3rem 1rem;
color: var(--text-muted);
}
.sub-tabs .tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ========================================================= /* =========================================================
SEARCH SEARCH
========================================================= */ ========================================================= */
@ -1020,570 +999,3 @@ body.dnd-mode .timer-display {
.volume-num::-webkit-inner-spin-button, .volume-num::-webkit-inner-spin-button,
.volume-num::-webkit-outer-spin-button { display: none; } .volume-num::-webkit-outer-spin-button { display: none; }
.volume-num { -moz-appearance: textfield; } .volume-num { -moz-appearance: textfield; }
/* ===== PODCAST COMPONENTS ===== */
.podcast-seek-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
flex: 1;
min-width: 0;
}
.skip-btn {
font-size: 0.72rem;
opacity: 0.8;
white-space: nowrap;
padding: 2px 4px;
}
.skip-btn:hover { opacity: 1; }
.speed-btns {
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
.speed-btn {
background: none;
border: 1px solid var(--border, #444);
color: var(--fg, #fff);
border-radius: 3px;
font-size: 0.68rem;
padding: 1px 4px;
cursor: pointer;
opacity: 0.6;
white-space: nowrap;
}
.speed-btn:hover { opacity: 1; }
.speed-btn.active {
opacity: 1;
border-color: var(--accent, #e63946);
color: var(--accent, #e63946);
}
.seek-slider {
flex: 1;
accent-color: var(--accent, #e63946);
cursor: pointer;
height: 4px;
}
.seek-time {
font-size: 0.75rem;
color: var(--text-muted, #888);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.podcast-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0 12px;
border-bottom: 1px solid var(--border, #333);
margin-bottom: 10px;
align-items: center;
}
.podcast-pane { }
.podcast-feed-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.podcast-feed-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border, #222);
}
.podcast-feed-info {
flex: 1;
min-width: 0;
}
.podcast-feed-title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.podcast-feed-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.podcast-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.podcast-thumb-lg {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.podcast-thumb-placeholder {
width: 48px;
height: 48px;
background: var(--surface, #1e1e2e);
border-radius: 4px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.podcast-unplayed-badge {
background: var(--accent, #e63946);
color: #fff;
border-radius: 99px;
padding: 1px 7px;
font-size: 0.7rem;
margin-left: 6px;
}
.episode-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.episode-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border, #222);
}
.episode-item.episode-played {
opacity: 0.5;
}
.episode-info {
flex: 1;
min-width: 0;
}
.episode-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9rem;
}
.episode-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.podcast-search-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.podcast-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid var(--border, #222);
}
.podcast-search-info {
flex: 1;
min-width: 0;
}
.podcast-queue-ol {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.podcast-feed-header {
margin-bottom: 12px;
}
.podcast-feed-header-inner {
display: flex;
align-items: center;
gap: 14px;
}
/* Clickable episode title (episode list) */
.ep-clickable {
cursor: pointer;
}
.ep-clickable:hover {
color: var(--accent, #e63946);
text-decoration: underline;
}
/* Clickable episode title / feed name in the now-playing bar */
.podcast-track-link,
.podcast-station-link {
cursor: pointer;
}
.podcast-track-link:hover,
.podcast-station-link:hover {
color: var(--accent, #e63946);
text-decoration: underline;
}
/* ===== SIDEBAR ===== */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 299;
}
.sidebar {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100vw;
/* stop above the now-playing bar */
bottom: 72px;
background: var(--surface, #111);
border-left: 1px solid var(--border, #333);
z-index: 300;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.22s ease;
overflow: hidden;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border, #333);
flex-shrink: 0;
}
.sidebar-title {
font-weight: bold;
font-size: 0.95rem;
line-height: 1.3;
}
.sidebar-close {
flex-shrink: 0;
margin-top: -2px;
}
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 16px;
font-size: 0.88rem;
line-height: 1.6;
}
/* Style links and basic HTML inside shownotes */
.sidebar-body a { color: var(--accent, #e63946); }
.sidebar-body p { margin: 0 0 10px; }
.sidebar-body ul, .sidebar-body ol { margin: 0 0 10px; padding-left: 20px; }
.sidebar-body h1, .sidebar-body h2, .sidebar-body h3 {
font-size: 0.9rem;
margin: 12px 0 4px;
}
@media (max-width: 600px) {
.sidebar { width: 100vw; }
.podcast-seek-bar { padding: 0 6px; }
.podcast-thumb { width: 40px; height: 40px; }
.podcast-thumb-lg { width: 60px; height: 60px; }
.podcast-feed-actions { flex-direction: column; }
.episode-actions { flex-direction: column; }
}
/* =========================================================
Ebook reader + book list + focus station
========================================================= */
/* --- Drop zone --- */
.book-drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 24px 16px;
text-align: center;
margin: 12px 0;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
.book-drop-zone:hover,
.book-drop-zone.drag-over {
border-color: var(--accent);
background: rgba(255,255,255,0.04);
}
.book-drop-zone label {
color: var(--accent);
cursor: pointer;
}
.book-key-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
/* --- Book list --- */
.book-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.book-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
gap: 12px;
}
.book-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.book-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.book-author,
.book-progress {
font-size: 12px;
}
.book-item-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* --- PDF pages --- */
.pdf-page-wrapper {
margin: 0 auto 1rem;
display: flex;
justify-content: center;
}
.pdf-page {
display: block;
max-width: 100%;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
/* --- Reader overlay --- */
.reader-overlay {
position: fixed;
top: var(--nav-h);
left: 0;
right: 0;
bottom: var(--bar-h);
background: var(--bg);
z-index: 200;
display: flex;
flex-direction: column;
}
.reader-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 12px;
}
.reader-title {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.reader-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.reader-progress-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.reader-content {
flex: 1;
overflow-y: scroll;
padding: 24px 16px;
line-height: 1.8;
font-family: Georgia, 'Times New Roman', serif;
font-size: 16px;
font-weight: 400;
}
.reader-content > * {
max-width: 65ch;
margin-left: auto;
margin-right: auto;
}
.reader-content p {
margin-bottom: 1em;
}
.reader-content h1, .reader-content h2, .reader-content h3 {
margin: 1.4em 0 0.6em;
font-family: var(--font);
}
/* --- Focus station sidebar --- */
.focus-preset-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.focus-preset-list li.focus-preset-active button {
border-color: var(--accent);
color: var(--accent);
}
.focus-custom-input {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
/* --- Table of contents sidebar --- */
.toc-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.toc-entry {
display: block;
width: 100%;
text-align: left;
padding: 4px 0;
font-size: 13px;
white-space: normal;
word-break: break-word;
}
.toc-entry:hover {
color: var(--accent);
}
/* Focus station pending (playing interrupted) */
#focus-station-btn.focus-pending {
color: var(--accent);
animation: focus-pulse 1.4s ease-in-out infinite;
}
@keyframes focus-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* =========================================================
Reader feature additions
========================================================= */
/* Reader themes */
.reader-theme-sepia .reader-content { background:#f5e6c8; color:#3b2a1a; }
.reader-theme-bright .reader-content { background:#fff; color:#111; }
.reader-content > * { max-width:var(--reader-max-width,65ch); margin-left:auto; margin-right:auto; }
/* Inline panels */
.reader-settings-panel, .reader-search-bar {
display:flex; align-items:center; gap:12px; padding:8px 16px;
border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; background:var(--bg);
}
.reader-settings-panel input[type="range"] { width:80px; }
/* PDF inner container — shares origin with canvas so text layer aligns exactly */
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }
.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; }
.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); }
.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); }
.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); }
/* PDF invert */
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
/* PDF paginated */
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
.pdf-paginated .pdf-page-wrapper { margin:0; }
/* Highlight popover */
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }
.hl-color-btn:hover { border-color:#fff; }
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
/* Highlight marks */
.epub-highlight { border-radius:2px; cursor:pointer; }
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); }
.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); }
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
/* Search matches */
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
mark.reader-search-match.active { background:rgba(230,57,70,.7); }
#rs-search-count { font-size:12px; min-width:50px; }
/* Bookmarks sidebar */
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }
/* Toast */
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

22
static/js/pdf.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,8 +2,7 @@
* diora service worker caches the app shell for offline use. * diora service worker caches the app shell for offline use.
*/ */
const CACHE = 'diora-v2'; const CACHE = 'diora-v1';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/', '/',
'/static/css/app.css', '/static/css/app.css',
@ -27,7 +26,7 @@ self.addEventListener('activate', function (event) {
event.waitUntil( event.waitUntil(
caches.keys().then(function (keys) { caches.keys().then(function (keys) {
return Promise.all( return Promise.all(
keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; }) keys.filter(function (key) { return key !== CACHE; })
.map(function (key) { return caches.delete(key); }) .map(function (key) { return caches.delete(key); })
); );
}).then(function () { }).then(function () {
@ -41,34 +40,12 @@ self.addEventListener('fetch', function (event) {
// Only handle GET requests; let POST/SSE etc. pass through // Only handle GET requests; let POST/SSE etc. pass through
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
// Don't intercept SSE or API requests
const url = new URL(event.request.url); const url = new URL(event.request.url);
// Bypass for API/mutation endpoints
if (url.pathname.startsWith('/radio/sse/') || if (url.pathname.startsWith('/radio/sse/') ||
url.pathname.startsWith('/radio/record/') || url.pathname.startsWith('/radio/record/') ||
url.pathname.startsWith('/radio/affiliate/') || url.pathname.startsWith('/radio/affiliate/') ||
url.pathname.startsWith('/admin/') || url.pathname.startsWith('/admin/')) {
url.pathname.startsWith('/podcasts/progress/') ||
url.pathname.startsWith('/podcasts/queue/') ||
url.pathname === '/podcasts/feeds/add' ||
url.pathname.startsWith('/podcasts/feeds/add') ||
url.pathname.includes('/remove') ||
url.pathname.startsWith('/podcasts/feeds/refresh')) {
return;
}
// Podcast audio: serve from podcast cache first, then network
if (event.request.destination === 'audio') {
event.respondWith(
caches.open(PODCAST_CACHE).then(function (cache) {
return cache.match(event.request).then(function (cached) {
if (cached) return cached;
return fetch(event.request).then(function (response) {
return response;
});
});
})
);
return; return;
} }

View file

@ -33,29 +33,3 @@
</p> </p>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
(function () {
var form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', async function (e) {
var u = form.querySelector('[name=username]');
var p = form.querySelector('[name=password]');
if (!u || !p || !u.value || !p.value) return;
e.preventDefault();
try {
var enc = new TextEncoder();
var mat = await crypto.subtle.importKey('raw', enc.encode(p.value), 'PBKDF2', false, ['deriveKey']);
var key = await crypto.subtle.deriveKey(
{name: 'PBKDF2', salt: enc.encode('diora:' + u.value), iterations: 200000, hash: 'SHA-256'},
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
);
var raw = await crypto.subtle.exportKey('raw', key);
localStorage.setItem('diora_pending_enc_key', btoa(String.fromCharCode(...new Uint8Array(raw))));
} catch (err) {}
form.submit();
});
})();
</script>
{% endblock %}

View file

@ -36,29 +36,3 @@
</p> </p>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
(function () {
var form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', async function (e) {
var u = form.querySelector('[name=username]');
var p = form.querySelector('[name=password1]');
if (!u || !p || !u.value || !p.value) return;
e.preventDefault();
try {
var enc = new TextEncoder();
var mat = await crypto.subtle.importKey('raw', enc.encode(p.value), 'PBKDF2', false, ['deriveKey']);
var key = await crypto.subtle.deriveKey(
{name: 'PBKDF2', salt: enc.encode('diora:' + u.value), iterations: 200000, hash: 'SHA-256'},
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
);
var raw = await crypto.subtle.exportKey('raw', key);
localStorage.setItem('diora_pending_enc_key', btoa(String.fromCharCode(...new Uint8Array(raw))));
} catch (err) {}
form.submit();
});
})();
</script>
{% endblock %}

View file

@ -42,14 +42,7 @@
<!-- Background section --> <!-- Background section -->
<section class="settings-section"> <section class="settings-section">
<h2>Background</h2> <h2>Background</h2>
{% if request.user.profile.background_encrypted %} {% if request.user.profile.background_image_data %}
<p class="lastfm-description">Encrypted background is set. <span class="muted">(Preview not available — decrypted in browser on main page.)</span></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>
{% elif request.user.profile.background_image_data %}
<p> <p>
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background"> <img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
</p> </p>
@ -57,9 +50,9 @@
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-danger">Remove background</button> <button type="submit" class="btn btn-danger">Remove background</button>
</form> </form>
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p> <p style="margin-top:12px;">Upload a new image to replace it:</p>
{% else %} {% else %}
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p> <p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB).</p>
{% endif %} {% endif %}
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;"> <div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
<label class="btn" for="bg-upload-input">Choose file</label> <label class="btn" for="bg-upload-input">Choose file</label>
@ -88,70 +81,20 @@
function getCsrfToken() { function getCsrfToken() {
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || ''; return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
} }
// Minimal crypto helpers for settings page (duplicated from app.js — settings page doesn't load app.js)
function _bytesToBase64(buf) {
const bytes = new Uint8Array(buf);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str);
}
function _bytesToHex(buf) {
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function _base64ToBytes(b64) {
const str = atob(b64);
const buf = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
return buf;
}
async function _getOrCreateEncKey() {
const userId = {{ request.user.id }};
const storageKey = `diora_enc_key_${userId}`;
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const raw = _base64ToBytes(stored);
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
} catch (e) {}
}
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const raw = await crypto.subtle.exportKey('raw', key);
localStorage.setItem(storageKey, _bytesToBase64(raw));
return key;
}
async function uploadBackground(input) { async function uploadBackground(input) {
const file = input.files[0]; const file = input.files[0];
if (!file) return; if (!file) return;
const status = document.getElementById('bg-upload-status'); const status = document.getElementById('bg-upload-status');
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; status.textContent = 'Uploading…';
if (!allowedTypes.includes(file.type)) { const form = new FormData();
status.textContent = 'Only JPEG, PNG, or WebP images are allowed.'; form.append('file', file);
return; form.append('csrfmiddlewaretoken', getCsrfToken());
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = 'Image must be 5 MB or smaller.';
return;
}
status.textContent = 'Encrypting…';
try { try {
const key = await _getOrCreateEncKey(); const res = await fetch('/accounts/background/upload/', { method: 'POST', body: form });
const buf = await file.arrayBuffer();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf);
status.textContent = 'Uploading…';
const res = await fetch('/accounts/background/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({iv: _bytesToHex(iv), ciphertext: _bytesToBase64(ct), mime_type: file.type, file_size: file.size}),
});
const data = await res.json(); const data = await res.json();
if (data.ok) { location.reload(); } if (data.ok) { location.reload(); }
else { status.textContent = data.error || 'Upload failed'; } else { status.textContent = data.error || 'Upload failed'; }
} catch (e) { } catch (e) { status.textContent = 'Upload failed'; }
status.textContent = 'Upload failed: ' + e.message;
}
input.value = ''; input.value = '';
} }
</script> </script>

View file

@ -12,11 +12,18 @@
<link rel="apple-touch-icon" href="/static/icon-192.png"> <link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
<title>{% block title %}diora{% endblock %}</title> <title>{% block title %}diora{% endblock %}</title>
{% if encrypted_bg_json %} {% if user.is_authenticated and user.profile.background_image_data %}
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script> <style>
body {
background-image: url('{{ user.profile.background_image_data }}');
background-size: cover;
background-position: center;
background-attachment: fixed;
}
</style>
{% endif %} {% endif %}
</head> </head>
<body> <body{% if user.is_authenticated and user.profile.background_image_data %} data-bg="1"{% endif %}>
<nav class="navbar"> <nav class="navbar">
<a href="/" class="navbar-brand">diora</a> <a href="/" class="navbar-brand">diora</a>
<div class="navbar-links"> <div class="navbar-links">
@ -47,8 +54,6 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="/static/js/jszip.min.js"></script>
<script src="/static/js/pdf.min.js"></script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -18,21 +18,6 @@
</label> </label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button> <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> <button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)"></button>
<button class="btn-icon" id="focus-station-btn" onclick="openFocusStationSidebar()" title="Focus station">📻</button>
</div>
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">&thinsp;15</button>
<span class="seek-time" id="seek-current">0:00</span>
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
<span class="seek-time" id="seek-duration">0:00</span>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;</button>
<div class="speed-btns" id="speed-btns">
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
<button class="speed-btn" onclick="setPlaybackRate(1.25)">×</button>
<button class="speed-btn" onclick="setPlaybackRate(1.5)">×</button>
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
</div>
</div> </div>
<div class="timer-widget" id="timer-widget"> <div class="timer-widget" id="timer-widget">
<span class="timer-phase" id="timer-phase-label">focus</span> <span class="timer-phase" id="timer-phase-label">focus</span>
@ -59,149 +44,140 @@
<!-- ===== TABS ===== --> <!-- ===== TABS ===== -->
<div class="tabs" id="tabs"> <div class="tabs" id="tabs">
<button class="tab-btn active" onclick="showTab('radio')">Radio</button> <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> <button class="tab-btn" onclick="showTab('focus')">Focus</button>
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
<button class="tab-btn" onclick="showTab('books')">Books</button>
</div> </div>
<!-- ===== RADIO TAB ===== --> <!-- ===== SEARCH TAB ===== -->
<section class="tab-panel" id="tab-radio"> <section class="tab-panel" id="tab-search">
<div class="tabs sub-tabs" id="radio-sub-tabs"> <div class="search-bar">
<button class="tab-btn active" onclick="showRadioTab('search')">Search</button> <input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
<button class="tab-btn" onclick="showRadioTab('saved')">Saved</button> <button class="btn" onclick="doSearch()">Search</button>
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
</div> </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>
<!-- ===== SEARCH SUB-PANEL ===== --> <!-- ===== SAVED TAB ===== -->
<div class="sub-tab-panel" id="tab-search"> <section class="tab-panel" id="tab-saved" style="display:none;">
<div class="search-bar"> {% if featured_stations %}
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()"> <div class="featured-section">
<button class="btn" onclick="doSearch()">Search</button> <p class="featured-label">&#9733; Featured</p>
<ul class="featured-list">
{% for s in featured_stations %}
<li>
{% if s.favicon_url %}<img src="{{ s.favicon_url }}" class="station-favicon" alt="">{% endif %}
<button class="btn btn-sm" onclick="playStation('{{ s.url|escapejs }}', '{{ s.name|escapejs }}', null)">
&#9654; {{ s.name }}
</button>
{% if s.description %}<span class="muted">{{ s.description }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if user.is_authenticated %}
<div id="recommendations" class="recommendations-section">
<!-- populated by JS -->
</div> </div>
<div class="mood-chips" id="mood-chips"></div> <div class="import-bar">
<div id="curated-lists" class="curated-lists-container"></div> <label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
<div id="search-status" class="status-msg"></div> &#8679; Import M3U
<table class="data-table" id="search-results-table" style="display:none;"> </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> <thead>
<tr> <tr>
<th>&#9733;</th>
<th>Name</th> <th>Name</th>
<th>Bitrate</th> <th>Bitrate</th>
<th>Country</th> <th>Country</th>
<th>Tags</th> <th title="Notes"></th>
<th></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="search-results-body"></tbody> <tbody id="saved-tbody">
</table> {% for station in saved_stations %}
</div> <tr id="saved-row-{{ station.id }}" data-id="{{ station.id }}" data-url="{{ station.url }}" data-name="{{ station.name }}">
<td>
<!-- ===== SAVED SUB-PANEL ===== --> <button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
<div class="sub-tab-panel" id="tab-saved" style="display:none;"> onclick="toggleFav({{ station.id }})"
{% if featured_stations %} title="Toggle favorite">&#9733;</button>
<div class="featured-section"> </td>
<p class="featured-label">&#9733; Featured</p> <td class="station-name-cell">{{ station.name }}</td>
<ul class="featured-list"> <td>{{ station.bitrate }}</td>
{% for s in featured_stations %} <td>{{ station.country }}</td>
<li> <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>
{% if s.favicon_url %}<img src="{{ s.favicon_url }}" class="station-favicon" alt="">{% endif %} <td>
<button class="btn btn-sm" onclick="playStation('{{ s.url|escapejs }}', '{{ s.name|escapejs }}', null)"> <button class="btn btn-sm"
&#9654; {{ s.name }} onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
</button> &#9654; Play
{% if s.description %}<span class="muted">{{ s.description }}</span>{% endif %} </button>
</li> </td>
{% endfor %} <td>
</ul> <button class="btn btn-sm btn-danger"
</div> onclick="removeStation({{ station.id }})">
{% endif %} Remove
{% if user.is_authenticated %} </button>
<div id="recommendations" class="recommendations-section"> </td>
<!-- 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 %}
</div>
<!-- ===== HISTORY SUB-PANEL ===== -->
<div class="sub-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>
<th></th>
</tr>
</thead>
<tbody id="history-tbody">
{% for entry in history %}
<tr data-id="{{ entry.id }}">
<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>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove"></button></td>
</tr> </tr>
{% empty %} {% empty %}
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr> <tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> {% 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>
<th></th>
</tr>
</thead>
<tbody id="history-tbody">
{% for entry in history %}
<tr data-id="{{ entry.id }}">
<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>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove"></button></td>
</tr>
{% empty %}
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section> </section>
<!-- ===== FOCUS TAB ===== --> <!-- ===== FOCUS TAB ===== -->
@ -220,108 +196,6 @@
</table> </table>
</section> </section>
<!-- ===== PODCASTS TAB ===== -->
<section class="tab-panel" id="tab-podcasts" style="display:none;">
{% if user.is_authenticated %}
<div class="podcast-toolbar">
<button class="btn btn-sm" onclick="showPodcastView('feeds')">Feeds</button>
<button class="btn btn-sm" onclick="showPodcastView('inbox')">Inbox</button>
<button class="btn btn-sm" onclick="showPodcastView('queue')">Queue</button>
<button class="btn btn-sm" onclick="podcastSearchOpen()">+ Search</button>
<label class="btn btn-sm" for="opml-file-input">Import OPML</label>
<input type="file" id="opml-file-input" accept=".opml,.xml" style="display:none;" onchange="importOPML(this)">
<span id="opml-status" class="muted"></span>
</div>
<!-- Search pane -->
<div class="podcast-pane" id="podcast-search-pane" style="display:none;">
<div class="search-bar">
<input type="text" id="podcast-search-input" class="search-input" placeholder="Search podcasts…"
onkeydown="if(event.key==='Enter') doPodcastSearch()">
<button class="btn" onclick="doPodcastSearch()">Search</button>
</div>
<div id="podcast-search-status" class="status-msg"></div>
<div id="podcast-search-list" class="podcast-search-list"></div>
</div>
<!-- Feeds pane -->
<div class="podcast-pane" id="podcast-feeds-pane">
<div id="podcast-feed-list" class="podcast-feed-list">
<p class="muted">Loading…</p>
</div>
</div>
<!-- Inbox pane -->
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
<div id="podcast-inbox-list" class="episode-list"></div>
</div>
<!-- Episodes pane -->
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
<div id="podcast-feed-header" class="podcast-feed-header"></div>
<div id="podcast-episode-list" class="episode-list"></div>
</div>
<!-- Queue pane -->
<div class="podcast-pane" id="podcast-queue-pane" style="display:none;">
<ol id="podcast-queue-ol" class="podcast-queue-ol"></ol>
</div>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to subscribe to podcasts.
</p>
{% endif %}
</section>
<!-- ===== BOOKS TAB ===== -->
<section class="tab-panel" id="tab-books" style="display:none;">
{% if user.is_authenticated %}
<div class="book-drop-zone" id="book-drop-zone">
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
<span id="book-upload-status" class="muted"></span>
</div>
<div id="book-list" class="book-list"></div>
{% else %}
<p class="auth-prompt">
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
to use the encrypted ebook reader.
</p>
{% endif %}
</section>
<!-- ===== READER OVERLAY ===== -->
<div id="reader-overlay" class="reader-overlay" style="display:none;">
<div class="reader-header">
<span id="reader-title" class="reader-title"></span>
<div class="reader-header-actions">
<span class="reader-progress-wrap">
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span>
</span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button>
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button>
</div>
</div>
<div id="reader-content" class="reader-content"></div>
</div>
<!-- ===== SIDEBAR ===== -->
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
<aside id="sidebar" class="sidebar">
<div class="sidebar-header">
<span id="sidebar-title" class="sidebar-title"></span>
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Close"></button>
</div>
<div id="sidebar-body" class="sidebar-body"></div>
</aside>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -330,9 +204,6 @@
const INITIAL_SAVED = {{ saved_stations|safe }}; const INITIAL_SAVED = {{ saved_stations|safe }};
const INITIAL_FEATURED = {{ featured_stations|safe }}; const INITIAL_FEATURED = {{ featured_stations|safe }};
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }}; const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }};
const USER_ID = {{ user.id|default:"null" }};
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
</script> </script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
{% endblock %} {% endblock %}