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_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 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: def has_lastfm(self) -> bool:
return bool(self.lastfm_session_key) return bool(self.lastfm_session_key)

View file

@ -12,4 +12,5 @@ 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,10 +1,12 @@
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
@ -116,27 +118,36 @@ 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):
f = request.FILES.get('file') try:
if not f: body = json.loads(request.body)
return JsonResponse({'error': 'no file'}, status=400) except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
ext = f.name.rsplit('.', 1)[-1].lower() if '.' in f.name else '' iv = body.get('iv', '').strip()
mime_map = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'webp': 'image/webp'} ciphertext = body.get('ciphertext', '').strip()
if ext not in mime_map: mime_type = body.get('mime_type', '').strip()
return JsonResponse({'error': 'only jpg, png, or webp allowed'}, status=400) 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) 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 = data_url profile.background_image_data = ''
profile.save(update_fields=['background_image_data']) profile.background_encrypted = ciphertext
return JsonResponse({'ok': True, 'url': data_url}) 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 @login_required
@ -144,10 +155,32 @@ 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.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') 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

@ -1,7 +1,11 @@
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')
@ -23,8 +27,15 @@ 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',
@ -60,9 +71,12 @@ 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,5 +6,7 @@ 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)

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 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
@ -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 = { 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,3 +3,4 @@ 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

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. * 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 = [ const SHELL = [
'/', '/',
'/static/css/app.css', '/static/css/app.css',
@ -26,7 +27,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; }) keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
.map(function (key) { return caches.delete(key); }) .map(function (key) { return caches.delete(key); })
); );
}).then(function () { }).then(function () {
@ -40,12 +41,34 @@ 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,3 +33,29 @@
</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,3 +36,29 @@
</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,7 +42,14 @@
<!-- Background section --> <!-- Background section -->
<section class="settings-section"> <section class="settings-section">
<h2>Background</h2> <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> <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>
@ -50,9 +57,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:</p> <p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
{% else %} {% 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 %} {% 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>
@ -81,20 +88,70 @@
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');
status.textContent = 'Uploading…'; const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
const form = new FormData(); if (!allowedTypes.includes(file.type)) {
form.append('file', file); status.textContent = 'Only JPEG, PNG, or WebP images are allowed.';
form.append('csrfmiddlewaretoken', getCsrfToken()); return;
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = 'Image must be 5 MB or smaller.';
return;
}
status.textContent = 'Encrypting…';
try { 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(); 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) { status.textContent = 'Upload failed'; } } catch (e) {
status.textContent = 'Upload failed: ' + e.message;
}
input.value = ''; input.value = '';
} }
</script> </script>

View file

@ -12,18 +12,11 @@
<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 user.is_authenticated and user.profile.background_image_data %} {% if encrypted_bg_json %}
<style> <script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
body {
background-image: url('{{ user.profile.background_image_data }}');
background-size: cover;
background-position: center;
background-attachment: fixed;
}
</style>
{% endif %} {% endif %}
</head> </head>
<body{% if user.is_authenticated and user.profile.background_image_data %} data-bg="1"{% endif %}> <body>
<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">
@ -54,6 +47,8 @@
{% 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>