Add gPodder sync API and bump SW cache to v5
- 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:
parent
2fad4a726c
commit
824b77a033
10 changed files with 407 additions and 1 deletions
|
|
@ -29,6 +29,7 @@ INSTALLED_APPS = [
|
||||||
'accounts',
|
'accounts',
|
||||||
'podcasts',
|
'podcasts',
|
||||||
'books',
|
'books',
|
||||||
|
'gpodder',
|
||||||
]
|
]
|
||||||
|
|
||||||
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,6 @@ urlpatterns = [
|
||||||
path('accounts/', include('accounts.urls')),
|
path('accounts/', include('accounts.urls')),
|
||||||
path('podcasts/', include('podcasts.urls')),
|
path('podcasts/', include('podcasts.urls')),
|
||||||
path('books/', include('books.urls')),
|
path('books/', include('books.urls')),
|
||||||
|
path('api/2/', include('gpodder.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)
|
||||||
|
|
|
||||||
0
gpodder/__init__.py
Normal file
0
gpodder/__init__.py
Normal file
6
gpodder/apps.py
Normal file
6
gpodder/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GpodderConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'gpodder'
|
||||||
62
gpodder/migrations/0001_initial.py
Normal file
62
gpodder/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
gpodder/migrations/__init__.py
Normal file
0
gpodder/migrations/__init__.py
Normal file
47
gpodder/models.py
Normal file
47
gpodder/models.py
Normal 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
12
gpodder/urls.py
Normal 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
277
gpodder/views.py
Normal 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(),
|
||||||
|
})
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v4';
|
const CACHE = 'diora-v5';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue