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',
|
||||
'podcasts',
|
||||
'books',
|
||||
'gpodder',
|
||||
]
|
||||
|
||||
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
|
|
|||
|
|
@ -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
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.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v4';
|
||||
const CACHE = 'diora-v5';
|
||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue