diff --git a/diora/settings.py b/diora/settings.py index b1fd840..d39342e 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -29,6 +29,7 @@ INSTALLED_APPS = [ 'accounts', 'podcasts', 'books', + 'gpodder', ] EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB diff --git a/diora/urls.py b/diora/urls.py index fcd5ab5..757a8b4 100644 --- a/diora/urls.py +++ b/diora/urls.py @@ -8,5 +8,6 @@ urlpatterns = [ path('accounts/', include('accounts.urls')), path('podcasts/', include('podcasts.urls')), path('books/', include('books.urls')), + path('api/2/', include('gpodder.urls')), path('', include('radio.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/gpodder/__init__.py b/gpodder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gpodder/apps.py b/gpodder/apps.py new file mode 100644 index 0000000..c9a7d2e --- /dev/null +++ b/gpodder/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GpodderConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gpodder' diff --git a/gpodder/migrations/0001_initial.py b/gpodder/migrations/0001_initial.py new file mode 100644 index 0000000..7dda170 --- /dev/null +++ b/gpodder/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.29 on 2026-03-20 05:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GpodderDevice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('device_id', models.CharField(max_length=64)), + ('caption', models.CharField(blank=True, max_length=100)), + ('type', models.CharField(default='other', max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gpodder_devices', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'device_id')}, + }, + ), + migrations.CreateModel( + name='GpodderSubscriptionChange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(max_length=1000)), + ('action', models.CharField(max_length=6)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gpodder_sub_changes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['timestamp'], + }, + ), + migrations.CreateModel( + name='GpodderEpisodeAction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('podcast', models.URLField(max_length=1000)), + ('episode', models.URLField(max_length=1000)), + ('action', models.CharField(max_length=10)), + ('timestamp', models.DateTimeField()), + ('started', models.IntegerField(blank=True, null=True)), + ('position', models.IntegerField(blank=True, null=True)), + ('total', models.IntegerField(blank=True, null=True)), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='gpodder.gpodderdevice')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gpodder_episode_actions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + ] diff --git a/gpodder/migrations/__init__.py b/gpodder/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gpodder/models.py b/gpodder/models.py new file mode 100644 index 0000000..5c3b334 --- /dev/null +++ b/gpodder/models.py @@ -0,0 +1,47 @@ +from django.db import models +from django.contrib.auth.models import User + + +class GpodderDevice(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gpodder_devices') + device_id = models.CharField(max_length=64) + caption = models.CharField(max_length=100, blank=True) + type = models.CharField(max_length=20, default='other') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'device_id') + + def __str__(self): + return f"{self.user.username}/{self.device_id}" + + +class GpodderSubscriptionChange(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gpodder_sub_changes') + url = models.URLField(max_length=1000) + action = models.CharField(max_length=6) # 'add' or 'remove' + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['timestamp'] + + def __str__(self): + return f"{self.action} {self.url} ({self.user.username})" + + +class GpodderEpisodeAction(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gpodder_episode_actions') + device = models.ForeignKey(GpodderDevice, on_delete=models.SET_NULL, null=True, blank=True) + podcast = models.URLField(max_length=1000) + episode = models.URLField(max_length=1000) + action = models.CharField(max_length=10) # play, download, delete, new + timestamp = models.DateTimeField() + started = models.IntegerField(null=True, blank=True) + position = models.IntegerField(null=True, blank=True) + total = models.IntegerField(null=True, blank=True) + + class Meta: + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.action} {self.episode} ({self.user.username})" diff --git a/gpodder/urls.py b/gpodder/urls.py new file mode 100644 index 0000000..fbb7d1c --- /dev/null +++ b/gpodder/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('auth//login.json', views.auth_login), + path('auth//logout.json', views.auth_logout), + path('devices/.json', views.devices_list), + path('devices//.json', views.device_update), + path('subscriptions/.json', views.subscriptions_all), + path('subscriptions//.json', views.subscriptions_by_device), + path('episodes/.json', views.episode_actions), +] diff --git a/gpodder/views.py b/gpodder/views.py new file mode 100644 index 0000000..6aa4292 --- /dev/null +++ b/gpodder/views.py @@ -0,0 +1,277 @@ +import base64 +import json +from datetime import datetime, timezone +from functools import wraps + +from django.contrib.auth import authenticate, login, logout +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from podcasts.models import PodcastFeed, PodcastEpisode, EpisodeProgress +from podcasts.views import _refresh_feed +from .models import GpodderDevice, GpodderSubscriptionChange, GpodderEpisodeAction + + +def _basic_auth(request): + auth = request.META.get('HTTP_AUTHORIZATION', '') + if not auth.startswith('Basic '): + return None + try: + credentials = base64.b64decode(auth[6:]).decode('utf-8') + username, _, password = credentials.partition(':') + user = authenticate(request, username=username, password=password) + if user: + login(request, user) + return user + except Exception: + return None + + +def _resolve_user(request, username): + if request.user.is_authenticated and request.user.username == username: + return request.user + user = _basic_auth(request) + if user and user.username == username: + return user + return None + + +def gpodder_auth(func): + @wraps(func) + def wrapper(request, username, *args, **kwargs): + user = _resolve_user(request, username) + if not user: + return JsonResponse({'error': 'unauthorized'}, status=401) + return func(request, username, *args, **kwargs) + return wrapper + + +def _now_ts(): + return int(datetime.now(timezone.utc).timestamp()) + + +def _get_or_create_device(user, device_id): + device, _ = GpodderDevice.objects.get_or_create( + user=user, device_id=device_id, + defaults={'caption': device_id, 'type': 'other'} + ) + return device + + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def auth_login(request, username): + user = _resolve_user(request, username) + if not user: + return JsonResponse({'error': 'unauthorized'}, status=401) + return JsonResponse({'username': user.username}) + + +@csrf_exempt +@require_http_methods(['GET', 'POST']) +def auth_logout(request, username): + logout(request) + return JsonResponse({}) + + +# --------------------------------------------------------------------------- +# Devices +# --------------------------------------------------------------------------- + +@csrf_exempt +@gpodder_auth +def devices_list(request, username): + devices = GpodderDevice.objects.filter(user=request.user) + return JsonResponse([ + {'id': d.device_id, 'caption': d.caption, 'type': d.type, 'subscriptions': 0} + for d in devices + ], safe=False) + + +@csrf_exempt +@gpodder_auth +def device_update(request, username, deviceid): + device = _get_or_create_device(request.user, deviceid) + if request.method == 'POST': + try: + body = json.loads(request.body or '{}') + if 'caption' in body: + device.caption = body['caption'][:100] + if 'type' in body: + device.type = body['type'][:20] + device.save() + except (json.JSONDecodeError, ValueError): + pass + return JsonResponse({'id': device.device_id, 'caption': device.caption, 'type': device.type}) + + +# --------------------------------------------------------------------------- +# Subscriptions +# --------------------------------------------------------------------------- + +def _subscription_changes_since(user, since_ts): + qs = GpodderSubscriptionChange.objects.filter(user=user) + if since_ts: + try: + since_dt = datetime.fromtimestamp(int(since_ts), tz=timezone.utc) + qs = qs.filter(timestamp__gt=since_dt) + except (ValueError, OSError): + pass + + added = list(qs.filter(action='add').values_list('url', flat=True).distinct()) + removed = list(qs.filter(action='remove').values_list('url', flat=True).distinct()) + # If a URL appears in both, the latest action wins — keep only consistent entries + added = [u for u in added if u not in removed] + removed = [u for u in removed if u not in added] + return added, removed + + +@csrf_exempt +@gpodder_auth +def subscriptions_by_device(request, username, deviceid): + _get_or_create_device(request.user, deviceid) + + if request.method == 'GET': + since = request.GET.get('since') + if since: + added, removed = _subscription_changes_since(request.user, since) + return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts()}) + else: + urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True)) + return JsonResponse(urls, safe=False) + + elif request.method == 'POST': + try: + body = json.loads(request.body or '{}') + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + for url in body.get('add', []): + if not url: + continue + feed, created = PodcastFeed.objects.get_or_create( + user=request.user, rss_url=url, + defaults={'title': url} + ) + if created: + try: + _refresh_feed(feed) + except Exception: + pass + GpodderSubscriptionChange.objects.create(user=request.user, url=url, action='add') + + for url in body.get('remove', []): + if not url: + continue + PodcastFeed.objects.filter(user=request.user, rss_url=url).delete() + GpodderSubscriptionChange.objects.create(user=request.user, url=url, action='remove') + + return JsonResponse({'timestamp': _now_ts(), 'update_urls': []}) + + +@csrf_exempt +@gpodder_auth +def subscriptions_all(request, username): + since = request.GET.get('since') + if since: + added, removed = _subscription_changes_since(request.user, since) + return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts()}) + else: + urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True)) + return JsonResponse(urls, safe=False) + + +# --------------------------------------------------------------------------- +# Episode actions +# --------------------------------------------------------------------------- + +@csrf_exempt +@gpodder_auth +def episode_actions(request, username): + if request.method == 'POST': + try: + actions = json.loads(request.body or '[]') + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + if not isinstance(actions, list): + actions = [actions] + + for a in actions: + if not isinstance(a, dict): + continue + podcast_url = a.get('podcast', '') + episode_url = a.get('episode', '') + action = a.get('action', '') + if not (podcast_url and episode_url and action): + continue + + ts_str = a.get('timestamp', '') + try: + ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00')) if ts_str else datetime.now(timezone.utc) + except (ValueError, AttributeError): + ts = datetime.now(timezone.utc) + + device_id = a.get('device', '') + device = _get_or_create_device(request.user, device_id) if device_id else None + + GpodderEpisodeAction.objects.create( + user=request.user, + device=device, + podcast=podcast_url, + episode=episode_url, + action=action, + timestamp=ts, + started=a.get('started'), + position=a.get('position'), + total=a.get('total'), + ) + + # Sync playback position to EpisodeProgress + if action == 'play' and a.get('position') is not None: + try: + ep = PodcastEpisode.objects.filter(audio_url=episode_url).first() + if ep: + pos = int(a['position']) + dur = ep.duration_seconds or 0 + played = dur > 0 and pos >= dur * 0.9 + EpisodeProgress.objects.update_or_create( + user=request.user, episode=ep, + defaults={'position_seconds': pos, 'played': played} + ) + except Exception: + pass + + return JsonResponse({'timestamp': _now_ts()}) + + elif request.method == 'GET': + since = request.GET.get('since') + qs = GpodderEpisodeAction.objects.filter(user=request.user) + if since: + try: + since_dt = datetime.fromtimestamp(int(since), tz=timezone.utc) + qs = qs.filter(timestamp__gt=since_dt) + except (ValueError, OSError): + pass + + return JsonResponse({ + 'actions': [ + { + 'podcast': a.podcast, + 'episode': a.episode, + 'device': a.device.device_id if a.device else '', + 'action': a.action, + 'timestamp': a.timestamp.strftime('%Y-%m-%dT%H:%M:%S'), + 'started': a.started, + 'position': a.position, + 'total': a.total, + } + for a in qs[:500] + ], + 'timestamp': _now_ts(), + }) diff --git a/static/js/sw.js b/static/js/sw.js index bb6afc0..63ce68b 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v4'; +const CACHE = 'diora-v5'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',