Add podcast feature with feed management, Docker cron, and ebook reader assets
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 13s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marwin Schulz 2026-03-19 13:39:59 +01:00
parent 6d391587c8
commit 2bd83f6315
29 changed files with 1180 additions and 39 deletions

18
Dockerfile.cron Normal file
View file

@ -0,0 +1,18 @@
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

@ -0,0 +1,38 @@
# 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,7 +9,12 @@ class UserProfile(models.Model):
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_data = models.TextField(blank=True) # base64 data URL
background_image_data = models.TextField(blank=True) # base64 data URL (legacy)
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:
return bool(self.lastfm_session_key)

View file

@ -12,4 +12,5 @@ urlpatterns = [
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'),
path('focus-station/', views.save_focus_station, name='save_focus_station'),
]

View file

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

View file

@ -1,7 +1,11 @@
import mimetypes
import os
from pathlib import Path
from dotenv import load_dotenv
mimetypes.add_type('application/javascript', '.js')
mimetypes.add_type('text/css', '.css')
# Load .env file from the project root
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
@ -23,8 +27,15 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'radio',
'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 = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
@ -60,9 +71,12 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.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 = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},

View file

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

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
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

0
podcasts/__init__.py Normal file
View file

30
podcasts/admin.py Normal file
View file

@ -0,0 +1,30 @@
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',)

6
podcasts/apps.py Normal file
View file

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

View file

View file

View file

@ -0,0 +1,34 @@
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

@ -0,0 +1,86 @@
# 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

71
podcasts/models.py Normal file
View file

@ -0,0 +1,71 @@
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}'

19
podcasts/urls.py Normal file
View file

@ -0,0 +1,19 @@
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'),
]

550
podcasts/views.py Normal file
View file

@ -0,0 +1,550 @@
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,6 +2,7 @@ import json
import time
import urllib.parse
from datetime import datetime
from django.core.serializers.json import DjangoJSONEncoder
import requests
from django.db.models import Count
@ -60,11 +61,37 @@ 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 = {
'saved_stations': saved_stations,
'history': history,
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
'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)

View file

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

13
static/js/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
static/js/pdf.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
static/js/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,8 @@
* diora service worker caches the app shell for offline use.
*/
const CACHE = 'diora-v1';
const CACHE = 'diora-v2';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/',
'/static/css/app.css',
@ -26,7 +27,7 @@ self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.filter(function (key) { return key !== CACHE; })
keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
.map(function (key) { return caches.delete(key); })
);
}).then(function () {
@ -40,12 +41,34 @@ 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);
// Bypass for API/mutation endpoints
if (url.pathname.startsWith('/radio/sse/') ||
url.pathname.startsWith('/radio/record/') ||
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;
}

View file

@ -33,3 +33,29 @@
</p>
</div>
{% 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,3 +36,29 @@
</p>
</div>
{% 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,7 +42,14 @@
<!-- Background section -->
<section class="settings-section">
<h2>Background</h2>
{% if request.user.profile.background_image_data %}
{% if request.user.profile.background_encrypted %}
<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>
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
</p>
@ -50,9 +57,9 @@
{% 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>
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
{% else %}
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB).</p>
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p>
{% endif %}
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
<label class="btn" for="bg-upload-input">Choose file</label>
@ -81,20 +88,70 @@
function getCsrfToken() {
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) {
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());
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
status.textContent = 'Only JPEG, PNG, or WebP images are allowed.';
return;
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = 'Image must be 5 MB or smaller.';
return;
}
status.textContent = 'Encrypting…';
try {
const res = await fetch('/accounts/background/upload/', { method: 'POST', body: form });
const key = await _getOrCreateEncKey();
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();
if (data.ok) { location.reload(); }
else { status.textContent = data.error || 'Upload failed'; }
} catch (e) { status.textContent = 'Upload failed'; }
} catch (e) {
status.textContent = 'Upload failed: ' + e.message;
}
input.value = '';
}
</script>

View file

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