Add gPodder sync API and bump SW cache to v5
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 17s

- Implement gPodder API v2 compatible endpoints at /api/2/:
  - Auth: login/logout via HTTP Basic Auth or session
  - Devices: list and register sync devices
  - Subscriptions: get/add/remove per device, delta sync with ?since=
  - Episode actions: upload play/position events, syncs to EpisodeProgress
- Server URL for AntennaPod: https://diora.creamfresh.xyz/api/2/
- Bump SW cache diora-v4 → v5 to force re-fetch of updated app.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-03-20 06:36:41 +01:00
parent 2fad4a726c
commit 824b77a033
10 changed files with 407 additions and 1 deletions

View file

@ -29,6 +29,7 @@ INSTALLED_APPS = [
'accounts',
'podcasts',
'books',
'gpodder',
]
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB

View file

@ -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)

0
gpodder/__init__.py Normal file
View file

6
gpodder/apps.py Normal file
View file

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

View file

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

View file

47
gpodder/models.py Normal file
View file

@ -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})"

12
gpodder/urls.py Normal file
View file

@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path('auth/<str:username>/login.json', views.auth_login),
path('auth/<str:username>/logout.json', views.auth_logout),
path('devices/<str:username>.json', views.devices_list),
path('devices/<str:username>/<str:deviceid>.json', views.device_update),
path('subscriptions/<str:username>.json', views.subscriptions_all),
path('subscriptions/<str:username>/<str:deviceid>.json', views.subscriptions_by_device),
path('episodes/<str:username>.json', views.episode_actions),
]

277
gpodder/views.py Normal file
View file

@ -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(),
})

View file

@ -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',