diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml
index a95c9a4..39d8ef1 100644
--- a/.forgejo/workflows/test.yml
+++ b/.forgejo/workflows/test.yml
@@ -29,7 +29,40 @@ jobs:
/tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
- name: Django system check
- run: /tmp/venv/bin/python manage.py check
+ # The SQLite file lives in data/ (a volume in production, git-ignored).
+ # Since the podcast models have JSON fields, the check opens the
+ # database to ask whether it supports them — so the folder must exist.
+ run: |
+ mkdir -p data
+ /tmp/venv/bin/python manage.py check
- name: Run tests
run: /tmp/venv/bin/python manage.py test
+
+ e2e:
+ # The browser tests (e2e/, docs/refactoring.md): a real runserver on its own
+ # throwaway database, a book uploaded, read, highlighted, the theme switched.
+ runs-on: ubuntu-latest
+ container:
+ image: catthehacker/ubuntu:act-24.04
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Python dependencies
+ run: |
+ python -m venv /tmp/venv
+ /tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
+
+ - name: Install Playwright and Chromium
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Browser tests
+ env:
+ CI: '1'
+ E2E_PYTHON: /tmp/venv/bin/python
+ run: |
+ mkdir -p data
+ npx playwright test
diff --git a/accounts/middleware.py b/accounts/middleware.py
index 3ceb447..addb990 100644
--- a/accounts/middleware.py
+++ b/accounts/middleware.py
@@ -22,4 +22,9 @@ class ApiTokenAuthMiddleware:
api_token = ApiToken.objects.select_related('user').filter(token=token).first()
if api_token is not None:
request.user = api_token.user
+ # Browsers never attach this header on their own, so a
+ # token request can't be a cross-site forgery; native
+ # clients have no CSRF cookie to send. (CsrfViewMiddleware
+ # checks this flag in process_view, which runs after us.)
+ request._dont_enforce_csrf_checks = True
return self.get_response(request)
diff --git a/accounts/migrations/0006_webdav_inbox.py b/accounts/migrations/0006_webdav_inbox.py
new file mode 100644
index 0000000..0588bad
--- /dev/null
+++ b/accounts/migrations/0006_webdav_inbox.py
@@ -0,0 +1,33 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0005_webdavsource'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='webdavsource',
+ name='inbox',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.CreateModel(
+ name='WebDAVSeen',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('path', models.CharField(max_length=1000)),
+ ('size', models.BigIntegerField(default=0)),
+ ('modified', models.CharField(blank=True, max_length=100)),
+ ('claimed_at', models.DateTimeField(blank=True, null=True)),
+ ('done_at', models.DateTimeField(blank=True, null=True)),
+ ('result', models.CharField(blank=True, max_length=20)),
+ ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seen', to='accounts.webdavsource')),
+ ],
+ options={
+ 'unique_together': {('source', 'path')},
+ },
+ ),
+ ]
diff --git a/accounts/migrations/0007_uservault.py b/accounts/migrations/0007_uservault.py
new file mode 100644
index 0000000..5f7570e
--- /dev/null
+++ b/accounts/migrations/0007_uservault.py
@@ -0,0 +1,28 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0006_webdav_inbox'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UserVault',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=40)),
+ ('ct', models.TextField()),
+ ('iv', models.CharField(max_length=64)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vault', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'unique_together': {('user', 'name')},
+ },
+ ),
+ ]
diff --git a/accounts/models.py b/accounts/models.py
index 38e8229..c06b29d 100644
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -56,6 +56,9 @@ class WebDAVSource(models.Model):
username = models.CharField(max_length=200, blank=True)
password = models.CharField(max_length=500, blank=True)
root_path = models.CharField(max_length=500, blank=True, default='')
+ # "Eingang" (docs/library.md): new books in it come into the library by themselves —
+ # a device with the key fetches, encrypts and uploads them (WebDAVSeen keeps which).
+ inbox = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
@@ -89,6 +92,36 @@ class WebDAVSource(models.Model):
return url
+class WebDAVSeen(models.Model):
+ """A file of an "Eingang" source a device took (or is taking) into the library: taken
+ files aren't offered again; a claim keeps two devices from taking the same file at once
+ and runs out when a device didn't finish."""
+ source = models.ForeignKey(WebDAVSource, on_delete=models.CASCADE, related_name='seen')
+ path = models.CharField(max_length=1000)
+ size = models.BigIntegerField(default=0)
+ modified = models.CharField(max_length=100, blank=True)
+ claimed_at = models.DateTimeField(null=True, blank=True)
+ done_at = models.DateTimeField(null=True, blank=True)
+ result = models.CharField(max_length=20, blank=True) # imported | known | too_big | failed
+
+ class Meta:
+ unique_together = ('source', 'path')
+
+
+class UserVault(models.Model):
+ """Small private settings of the devices, end-to-end encrypted with the book key
+ (docs/ai.md: the Anthropic API key of the "KI" features). The server keeps the blob,
+ one per name, and never sees what is in it."""
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='vault')
+ name = models.CharField(max_length=40)
+ ct = models.TextField()
+ iv = models.CharField(max_length=64)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ('user', 'name')
+
+
def _generate_token():
return secrets.token_hex(32)
diff --git a/accounts/sync.py b/accounts/sync.py
index dcd2422..69742ff 100644
--- a/accounts/sync.py
+++ b/accounts/sync.py
@@ -3,7 +3,8 @@ from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
-from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
+from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks, book_rows
+from books.views import _now_ms, progress_state
from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue
from radio.models import SavedStation
@@ -19,21 +20,22 @@ def sync_snapshot(request):
endpoints (e.g. GET /books//data/), which this same token also
unlocks. Writes (progress, highlights, bookmarks, ...) go through those
existing endpoints too, to reuse their merge semantics rather than
- duplicating it here."""
+ duplicating it here.
+
+ ?only=books: just the books' part. The apps' book sync needs nothing else,
+ and the podcast part is by far the biggest (every episode ever played:
+ megabytes for an account that moved over from AntennaPod)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
user = request.user
- books = list(EBook.objects.filter(user=user).values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read'))
- for b in books:
- b['uploaded_at'] = b['uploaded_at'].isoformat()
+ books = [{k: b[k] for k in ('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')} for b in book_rows(user)]
- book_progress = list(
- EBookProgress.objects.filter(user=user).values('book_id', 'scroll_fraction', 'position_anchor', 'updated_at')
- )
- for p in book_progress:
- p['updated_at'] = p['updated_at'].isoformat()
+ book_progress = [
+ {'book_id': p.book_id, 'updated_at': p.updated_at.isoformat(), **progress_state(p)}
+ for p in EBookProgress.objects.filter(user=user)
+ ]
book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
for h in book_highlights:
@@ -43,6 +45,16 @@ def sync_snapshot(request):
for bm in book_bookmarks:
bm['updated_at'] = bm['updated_at'].isoformat()
+ if request.GET.get('only') == 'books':
+ return JsonResponse({
+ 'server_time': timezone.now().isoformat(),
+ 'server_now': _now_ms(),
+ 'books': books,
+ 'book_progress': book_progress,
+ 'book_highlights': book_highlights,
+ 'book_bookmarks': book_bookmarks,
+ })
+
podcast_feeds = list(
PodcastFeed.objects.filter(user=user)
.annotate(latest_episode_at=Max('episodes__pub_date'))
@@ -71,6 +83,7 @@ def sync_snapshot(request):
return JsonResponse({
'server_time': timezone.now().isoformat(),
+ 'server_now': _now_ms(),
'books': books,
'book_progress': book_progress,
'book_highlights': book_highlights,
diff --git a/accounts/tests.py b/accounts/tests.py
index 4d4e05a..a77ac03 100644
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -42,6 +42,20 @@ class ApiTokenAuthMiddlewareTests(TestCase):
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.status_code, 200)
+ def test_bearer_token_needs_no_csrf_token(self):
+ # import_m3u is not csrf_exempt: without a token it must still be protected.
+ from django.test import Client
+ client = Client(enforce_csrf_checks=True)
+ m3u = '#EXTM3U\n#EXTINF:-1,Test FM\nhttps://example.com/stream\n'
+ resp = client.post('/radio/import/', data=m3u, content_type='text/plain')
+ self.assertIn(resp.status_code, (302, 401, 403))
+ resp = client.post('/radio/import/', data=m3u, content_type='text/plain',
+ HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
+ self.assertNotEqual(resp.status_code, 403)
+ resp = client.post('/radio/import/', data=m3u, content_type='text/plain',
+ HTTP_AUTHORIZATION='Bearer not-a-real-token')
+ self.assertIn(resp.status_code, (302, 401, 403))
+
def test_regenerate_view_requires_login_and_rotates_token(self):
resp = self.client.post('/accounts/api-token/regenerate/')
self.assertEqual(resp.status_code, 302)
@@ -71,6 +85,12 @@ class SyncSnapshotTests(TestCase):
resp = self.client.get('/api/sync/')
self.assertEqual(resp.status_code, 401)
+ def test_only_books(self):
+ resp = self.client.get('/api/sync/?only=books', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
+ data = resp.json()
+ self.assertEqual(set(data), {'server_time', 'server_now', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'})
+ self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct')
+
def test_snapshot_shape_and_data(self):
resp = self.client.get('/api/sync/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.status_code, 200)
@@ -526,3 +546,136 @@ class CloudFetchCachingTests(TestCase):
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
self.assertEqual(resp['Cache-Control'], 'no-store')
+
+
+class WebDAVEditTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.other = User.objects.create_user(username='bob', password='pw12345678')
+ self.source = WebDAVSource.objects.create(
+ user=self.user, label='cloud', base_url='https://cloud.example.com',
+ username='alice', password='app-pw',
+ )
+
+ def _edit(self, pk, **data):
+ form = {'label': 'cloud', 'base_url': 'https://cloud.example.com', 'username': 'alice', 'root_path': ''}
+ form.update(data)
+ with patch('accounts.views.list_directory', return_value=[]):
+ return self.client.post(f'/accounts/webdav/{pk}/edit/', form)
+
+ def test_sets_start_folder_and_keeps_password(self):
+ self.client.force_login(self.user)
+ resp = self._edit(self.source.pk, root_path='Books', password='')
+ self.assertEqual(resp.status_code, 302)
+ self.source.refresh_from_db()
+ self.assertEqual(self.source.root_path, 'Books')
+ self.assertEqual(self.source.password, 'app-pw')
+
+ def test_new_password_replaces_the_old_one(self):
+ self.client.force_login(self.user)
+ self._edit(self.source.pk, password='new-pw')
+ self.source.refresh_from_db()
+ self.assertEqual(self.source.password, 'new-pw')
+
+ def test_name_and_url_are_required(self):
+ self.client.force_login(self.user)
+ self._edit(self.source.pk, label='', root_path='Books')
+ self.source.refresh_from_db()
+ self.assertEqual(self.source.root_path, '')
+
+ def test_foreign_source_is_not_editable(self):
+ self.client.force_login(self.other)
+ self._edit(self.source.pk, root_path='x')
+ self.source.refresh_from_db()
+ self.assertEqual(self.source.root_path, '')
+
+ def test_settings_page_does_not_show_the_password(self):
+ self.client.force_login(self.user)
+ html = self.client.get('/accounts/settings/').content.decode()
+ self.assertIn(f'/accounts/webdav/{self.source.pk}/edit/', html)
+ self.assertNotIn('app-pw', html)
+
+
+class CloudInboxTests(TestCase):
+ """An "Eingang" source (docs/library.md): its new books are handed out once, claimed for
+ one device for ten minutes, and not again once a device took them."""
+
+ def setUp(self):
+ self.user = User.objects.create_user('inbox', password='pw12345678')
+ self.client.force_login(self.user)
+ self.src = WebDAVSource.objects.create(user=self.user, label='Bücher', base_url='https://b.example.org/dav/',
+ username='d', password='x', inbox=True)
+ WebDAVSource.objects.create(user=self.user, label='andere', base_url='https://c.example.org/dav/')
+
+ def listing(self, path=''):
+ if path == '':
+ return [{'name': 'Sub', 'path': 'Sub', 'is_dir': True, 'size': 0, 'modified': '', 'is_book': False},
+ {'name': 'A - Eins (2020).epub', 'path': 'A - Eins (2020).epub', 'is_dir': False,
+ 'size': 100, 'modified': 'Mon', 'is_book': True},
+ {'name': 'notiz.txt', 'path': 'notiz.txt', 'is_dir': False, 'size': 1, 'modified': '', 'is_book': False}]
+ return [{'name': 'B.pdf', 'path': 'Sub/B.pdf', 'is_dir': False, 'size': 200, 'modified': '', 'is_book': True}]
+
+ def inbox(self):
+ with patch('books.webdav.list_directory', side_effect=lambda source, path='': self.listing(path)):
+ return self.client.get('/books/cloud/inbox/').json()
+
+ def done(self, path, result):
+ return self.client.post(f'/books/cloud/{self.src.pk}/done/', json.dumps({'path': path, 'result': result}),
+ content_type='application/json')
+
+ def test_new_books_once_then_claimed_then_done(self):
+ files = self.inbox()['files']
+ self.assertEqual(sorted(f['path'] for f in files), ['A - Eins (2020).epub', 'Sub/B.pdf'])
+ self.assertEqual(self.inbox()['files'], []) # claimed for this device: no one else gets them now
+ self.assertEqual(self.done('A - Eins (2020).epub', 'imported').status_code, 200)
+ self.assertEqual(self.done('Sub/B.pdf', 'failed').status_code, 200)
+ from accounts.models import WebDAVSeen
+ WebDAVSeen.objects.filter(path='Sub/B.pdf').update(claimed_at=None) # the claim ran out
+ self.assertEqual([f['path'] for f in self.inbox()['files']], ['Sub/B.pdf'])
+
+ def test_a_changed_file_counts_as_new(self):
+ self.inbox()
+ self.done('A - Eins (2020).epub', 'imported')
+ self.done('Sub/B.pdf', 'known')
+ self.assertEqual(self.inbox()['files'], [])
+ old = self.listing
+ self.listing = lambda path='': [dict(e, size=999) if e['path'].endswith('.epub') else e for e in old(path)]
+ self.assertEqual([f['path'] for f in self.inbox()['files']], ['A - Eins (2020).epub'])
+
+ def test_only_own_sources_and_valid_results(self):
+ other = User.objects.create_user('x', password='pw12345678')
+ foreign = WebDAVSource.objects.create(user=other, label='f', base_url='https://d.example.org/dav/', inbox=True)
+ self.assertEqual(self.client.post(f'/books/cloud/{foreign.pk}/done/', '{}', content_type='application/json').status_code, 404)
+ self.inbox()
+ self.assertEqual(self.done('A - Eins (2020).epub', 'whatever').status_code, 400)
+ self.client.logout()
+ self.assertEqual(self.client.get('/books/cloud/inbox/').status_code, 401)
+
+ def test_settings_switch_the_inbox(self):
+ r = self.client.post(f'/accounts/webdav/{self.src.pk}/edit/', {'label': 'Bücher', 'base_url': self.src.base_url})
+ self.assertEqual(r.status_code, 302)
+ self.src.refresh_from_db()
+ self.assertFalse(self.src.inbox)
+
+
+
+class VaultTests(TestCase):
+ """The vault keeps what the devices encrypted, per name; only for its owner."""
+
+ def test_keep_replace_remove(self):
+ u = User.objects.create_user('v', password='pw12345678')
+ self.client.force_login(u)
+ post = lambda body: self.client.post('/accounts/vault/ai/', json.dumps(body), content_type='application/json')
+ self.assertEqual(self.client.get('/accounts/vault/ai/').json(), {'ct': '', 'iv': ''})
+ self.assertEqual(post({'ct': 'abc', 'iv': 'i1'}).status_code, 200)
+ self.assertEqual(post({'ct': 'def', 'iv': 'i2'}).status_code, 200)
+ self.assertEqual(self.client.get('/accounts/vault/ai/').json(), {'ct': 'def', 'iv': 'i2'})
+ self.assertEqual(post({'iv': 'x'}).status_code, 400)
+ other = User.objects.create_user('w', password='pw12345678')
+ self.client.force_login(other)
+ self.assertEqual(self.client.get('/accounts/vault/ai/').json()['ct'], '')
+ self.client.force_login(u)
+ self.assertEqual(post({'ct': ''}).status_code, 200)
+ self.assertEqual(self.client.get('/accounts/vault/ai/').json()['ct'], '')
+ self.client.logout()
+ self.assertEqual(self.client.get('/accounts/vault/ai/').status_code, 401)
diff --git a/accounts/urls.py b/accounts/urls.py
index 4bee71f..341bd8d 100644
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -17,6 +17,8 @@ urlpatterns = [
path('change-password/', views.change_password, name='change_password'),
path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'),
path('webdav/add/', views.webdav_add, name='webdav_add'),
+ path('webdav//edit/', views.webdav_edit, name='webdav_edit'),
path('webdav//test/', views.webdav_test, name='webdav_test'),
path('webdav//delete/', views.webdav_delete, name='webdav_delete'),
+ path('vault//', views.vault, name='vault'),
]
diff --git a/accounts/views.py b/accounts/views.py
index 519c073..7b09742 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -12,7 +12,7 @@ from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module
-from .models import ApiToken, WebDAVSource
+from .models import ApiToken, UserVault, WebDAVSource
from .webdav import WebDAVError, list_directory
User = get_user_model()
@@ -97,7 +97,7 @@ def lastfm_connect(request):
return render(request, 'accounts/settings.html', {
'profile': request.user.profile,
'has_lastfm': request.user.profile.has_lastfm(),
- 'lastfm_error': f"Could not connect to Last.fm: {exc}",
+ 'lastfm_error': f"Verbindung zu Last.fm fehlgeschlagen: {exc}",
})
@@ -283,6 +283,7 @@ def webdav_add(request):
username=request.POST.get('username', '').strip()[:200],
password=request.POST.get('password', '')[:500],
root_path=request.POST.get('root_path', '').strip()[:500],
+ inbox=request.POST.get('inbox') == '1',
)
level, message = _probe_source(source)
@@ -290,6 +291,36 @@ def webdav_add(request):
return redirect('settings')
+@login_required
+@require_http_methods(['POST'])
+def webdav_edit(request, pk):
+ source = WebDAVSource.objects.filter(pk=pk, user=request.user).first()
+ if not source:
+ messages.error(request, 'Verbindung nicht gefunden.')
+ return redirect('settings')
+
+ label = request.POST.get('label', '').strip()
+ base_url = request.POST.get('base_url', '').strip()
+ if not label or not base_url:
+ messages.error(request, 'Name und Server-URL sind erforderlich.')
+ return redirect('settings')
+
+ source.label = label[:100]
+ source.base_url = base_url[:500]
+ source.username = request.POST.get('username', '').strip()[:200]
+ source.root_path = request.POST.get('root_path', '').strip()[:500]
+ source.inbox = request.POST.get('inbox') == '1'
+ # The stored password is never sent to the page: empty means keep it.
+ password = request.POST.get('password', '')
+ if password:
+ source.password = password[:500]
+ source.save()
+
+ level, message = _probe_source(source)
+ messages.add_message(request, level, message)
+ return redirect('settings')
+
+
@login_required
@require_http_methods(['POST'])
def webdav_test(request, pk):
@@ -310,3 +341,38 @@ def webdav_delete(request, pk):
if deleted:
messages.success(request, 'Verbindung entfernt.')
return redirect('settings')
+
+
+
+# ---------------------------------------------------------------------------
+# Vault: small device settings, end-to-end encrypted (docs/ai.md)
+# ---------------------------------------------------------------------------
+
+VAULT_MAX = 64 * 1024
+
+
+@csrf_exempt
+@require_http_methods(['GET', 'POST'])
+def vault(request, name):
+ """GET {ct, iv} ('' while there is none); POST {ct, iv} replaces it, {ct: ''} removes it.
+ The blob is encrypted on the devices with the book key; the server only keeps it."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ if request.method == 'GET':
+ v = UserVault.objects.filter(user=request.user, name=name).first()
+ return JsonResponse({'ct': v.ct if v else '', 'iv': v.iv if v else ''})
+ if len(request.body) > VAULT_MAX:
+ return JsonResponse({'error': 'too large'}, status=413)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ ct = body.get('ct') if isinstance(body, dict) else None
+ iv = body.get('iv') if isinstance(body, dict) else None
+ if ct == '':
+ UserVault.objects.filter(user=request.user, name=name).delete()
+ return JsonResponse({'ok': True})
+ if not isinstance(ct, str) or not isinstance(iv, str) or not iv or len(iv) > 64:
+ return JsonResponse({'error': 'ct and iv required'}, status=400)
+ UserVault.objects.update_or_create(user=request.user, name=name, defaults={'ct': ct, 'iv': iv})
+ return JsonResponse({'ok': True})
diff --git a/books/migrations/0005_progress_last_read_wins.py b/books/migrations/0005_progress_last_read_wins.py
new file mode 100644
index 0000000..baf8ff9
--- /dev/null
+++ b/books/migrations/0005_progress_last_read_wins.py
@@ -0,0 +1,43 @@
+from django.db import migrations, models
+
+
+def keep_what_we_know(apps, schema_editor):
+ """The continue point so far was the furthest place: it is that, and it was
+ last saved at updated_at."""
+ EBookProgress = apps.get_model('books', 'EBookProgress')
+ for p in EBookProgress.objects.all().iterator():
+ p.furthest_fraction = p.scroll_fraction
+ p.furthest_anchor = p.position_anchor
+ p.read_at = int(p.updated_at.timestamp() * 1000)
+ p.save(update_fields=['furthest_fraction', 'furthest_anchor', 'read_at'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0004_ebook_is_read'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='ebookprogress',
+ name='read_at',
+ field=models.BigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='ebookprogress',
+ name='device',
+ field=models.CharField(blank=True, default='', max_length=40),
+ ),
+ migrations.AddField(
+ model_name='ebookprogress',
+ name='furthest_fraction',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='ebookprogress',
+ name='furthest_anchor',
+ field=models.CharField(blank=True, default='', max_length=30),
+ ),
+ migrations.RunPython(keep_what_we_know, migrations.RunPython.noop),
+ ]
diff --git a/books/migrations/0006_covers_and_prefs.py b/books/migrations/0006_covers_and_prefs.py
new file mode 100644
index 0000000..488374e
--- /dev/null
+++ b/books/migrations/0006_covers_and_prefs.py
@@ -0,0 +1,36 @@
+# Generated by Django 6.1 on 2026-10-16 18:21
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0005_progress_last_read_wins'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='ebook',
+ name='cover_ct',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.AddField(
+ model_name='ebook',
+ name='cover_iv',
+ field=models.CharField(blank=True, default='', max_length=32),
+ ),
+ migrations.CreateModel(
+ name='LibraryPrefs',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('prefs_ct', models.TextField(blank=True, default='')),
+ ('prefs_iv', models.CharField(blank=True, default='', max_length=32)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library_prefs', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/books/migrations/0007_list_indexes.py b/books/migrations/0007_list_indexes.py
new file mode 100644
index 0000000..054c9b1
--- /dev/null
+++ b/books/migrations/0007_list_indexes.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.1 on 2026-10-19 13:24
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0006_covers_and_prefs'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name='ebook',
+ index=models.Index(fields=['user', 'uploaded_at', 'is_read'], name='ebook_list_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='ebook',
+ index=models.Index(fields=['user', 'cover_iv'], name='ebook_cover_idx'),
+ ),
+ ]
diff --git a/books/migrations/0008_ebook_file.py b/books/migrations/0008_ebook_file.py
new file mode 100644
index 0000000..e014bbc
--- /dev/null
+++ b/books/migrations/0008_ebook_file.py
@@ -0,0 +1,38 @@
+# Generated by Django 6.1 on 2026-10-19 15:02
+# The books' files move out of their rows into a table of their own (docs/database.md):
+# copied in one statement, then the two columns are dropped (ALTER TABLE DROP COLUMN).
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0007_list_indexes'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='EBookFile',
+ fields=[
+ ('book', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='file', serialize=False, to='books.ebook')),
+ ('data_ct', models.TextField()),
+ ('data_iv', models.CharField(max_length=32)),
+ ],
+ ),
+ migrations.RunSQL(
+ 'INSERT INTO books_ebookfile (book_id, data_ct, data_iv) SELECT id, data_ct, data_iv FROM books_ebook',
+ reverse_sql='UPDATE books_ebook SET '
+ 'data_ct = (SELECT f.data_ct FROM books_ebookfile f WHERE f.book_id = books_ebook.id), '
+ 'data_iv = (SELECT f.data_iv FROM books_ebookfile f WHERE f.book_id = books_ebook.id)',
+ ),
+ migrations.RemoveField(
+ model_name='ebook',
+ name='data_ct',
+ ),
+ migrations.RemoveField(
+ model_name='ebook',
+ name='data_iv',
+ ),
+ ]
diff --git a/books/models.py b/books/models.py
index 9bc06c7..e9e3b16 100644
--- a/books/models.py
+++ b/books/models.py
@@ -2,27 +2,91 @@ from django.db import models
from django.contrib.auth.models import User
+class EBookManager(models.Manager):
+ def create(self, data_ct=None, data_iv=None, **kwargs):
+ """A book, and with data_ct/data_iv its file (EBookFile) in the same go."""
+ book = super().create(**kwargs)
+ if data_ct is not None:
+ EBookFile.objects.create(book=book, data_ct=data_ct, data_iv=data_iv or '')
+ return book
+
+
class EBook(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebooks')
meta_ct = models.TextField() # base64 AES-GCM ciphertext of {title, author, filename}
meta_iv = models.CharField(max_length=32) # hex IV for metadata
- data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
- data_iv = models.CharField(max_length=32) # hex IV for EPUB data
+ # The file itself is in EBookFile (book.file), not here (docs/database.md).
uploaded_at = models.DateTimeField(auto_now_add=True)
is_read = models.BooleanField(default=False)
+ # The cover, end-to-end encrypted like the rest (docs/library.md): a client that has
+ # the file extracts it, encrypts it with the user's key and sends it once.
+ cover_ct = models.TextField(blank=True, default='')
+ cover_iv = models.CharField(max_length=32, blank=True, default='')
+
+ objects = EBookManager()
class Meta:
ordering = ['uploaded_at']
+ # The list reads these from indexes (book_rows()); they made it fast while the file
+ # was still in this row (docs/database.md), and cost nothing now.
+ indexes = [
+ models.Index(fields=['user', 'uploaded_at', 'is_read'], name='ebook_list_idx'),
+ models.Index(fields=['user', 'cover_iv'], name='ebook_cover_idx'),
+ ]
def __str__(self):
return f"EBook #{self.pk} (user={self.user_id})"
+class EBookFile(models.Model):
+ """A book's file, encrypted like everything else: base64 AES-GCM of the EPUB/PDF bytes.
+ In a table of its own (docs/database.md): with the file in the book's row, every change of a
+ title, a folder or "gelesen" rewrote megabytes, and the list read past every file."""
+ book = models.OneToOneField(EBook, on_delete=models.CASCADE, primary_key=True, related_name='file')
+ data_ct = models.TextField()
+ data_iv = models.CharField(max_length=32)
+
+ def __str__(self):
+ return f"EBookFile of #{self.book_id}"
+
+
+def book_rows(user):
+ """Every book of the user as {id, meta_ct, meta_iv, uploaded_at (ISO), is_read, has_cover},
+ oldest first — read without passing a book's file: meta_ct and meta_iv stand before it in
+ the row, the rest comes from the covering indexes ebook_list_idx and ebook_cover_idx."""
+ books = {b['id']: b for b in EBook.objects.filter(user=user).order_by().values('id', 'meta_ct', 'meta_iv')}
+ for book_id, at, read in EBook.objects.filter(user=user).order_by().values_list('id', 'uploaded_at', 'is_read'):
+ if book_id in books:
+ books[book_id].update(uploaded_at=at, is_read=read)
+ covered = set(EBook.objects.filter(user=user).exclude(cover_iv='').order_by().values_list('id', flat=True))
+ out = sorted((b for b in books.values() if 'uploaded_at' in b), key=lambda b: (b['uploaded_at'], b['id']))
+ for b in out:
+ b['uploaded_at'] = b['uploaded_at'].isoformat()
+ b['has_cover'] = b['id'] in covered
+ return out
+
+
+class LibraryPrefs(models.Model):
+ """The library's own settings, encrypted with the user's key (the folder order:
+ folder names are private, docs/library.md). One blob, replaced as a whole."""
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='library_prefs')
+ prefs_ct = models.TextField(blank=True, default='')
+ prefs_iv = models.CharField(max_length=32, blank=True, default='')
+ updated_at = models.DateTimeField(auto_now=True)
+
+
class EBookProgress(models.Model):
+ """Where to continue a book: the place it was last read, on any device
+ (docs/progress-sync.md), plus the furthest place ever reached."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
scroll_fraction = models.FloatField(default=0.0)
position_anchor = models.CharField(max_length=30, blank=True, default='')
+ # When the reader was at that place (ms since epoch, server clock); null before 2026-10-12.
+ read_at = models.BigIntegerField(null=True, blank=True)
+ device = models.CharField(max_length=40, blank=True, default='')
+ furthest_fraction = models.FloatField(default=0.0)
+ furthest_anchor = models.CharField(max_length=30, blank=True, default='')
updated_at = models.DateTimeField(auto_now=True)
class Meta:
diff --git a/books/tests.py b/books/tests.py
new file mode 100644
index 0000000..c8e711c
--- /dev/null
+++ b/books/tests.py
@@ -0,0 +1,292 @@
+"""The reading position: the place last read wins (docs/progress-sync.md)."""
+import json
+from unittest.mock import patch
+
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from accounts.models import ApiToken
+from .models import EBook, EBookProgress
+
+NOW = 1_760_270_000_000 # the server's clock in these tests (ms)
+MIN = 60_000
+
+
+class ProgressTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=self.user).token}'}
+ self.book = EBook.objects.create(user=self.user, meta_ct='m', meta_iv='i', data_ct='d', data_iv='i')
+ self.now = NOW
+ clock = patch('books.views._now_ms', side_effect=lambda: self.now)
+ clock.start()
+ self.addCleanup(clock.stop)
+
+ def save(self, anchor, frac=None, read_at=None, sent_at=None, device='Handy', **extra):
+ body = {'scroll_fraction': frac if frac is not None else 0.1, 'position_anchor': anchor, **extra}
+ if read_at is not None:
+ body.update(read_at=read_at, sent_at=sent_at if sent_at is not None else self.now, device=device)
+ resp = self.client.post(f'/books/{self.book.id}/progress/', json.dumps(body),
+ content_type='application/json', **self.auth)
+ self.assertEqual(resp.status_code, 200)
+ return resp.json()
+
+ def state(self):
+ return EBookProgress.objects.get(book=self.book)
+
+ # -- the new rule
+
+ def test_newer_place_wins_even_if_it_is_earlier_in_the_book(self):
+ self.save('300:0.5', read_at=NOW - 10 * MIN, device='Handy')
+ r = self.save('12:0.0', read_at=NOW - 1 * MIN, device='diora Desktop')
+ self.assertTrue(r['stored'])
+ p = self.state()
+ self.assertEqual((p.position_anchor, p.device, p.read_at), ('12:0.0', 'diora Desktop', NOW - MIN))
+ self.assertEqual(p.furthest_anchor, '300:0.5')
+
+ def test_an_older_write_arriving_late_does_not_win(self):
+ # The phone read offline at 10:00 and syncs at 12:00; the desktop read at 11:00.
+ self.save('40:0.0', read_at=NOW - 60 * MIN, device='diora Desktop')
+ r = self.save('90:0.0', read_at=NOW - 120 * MIN, device='Handy')
+ self.assertFalse(r['stored'])
+ self.assertEqual(r['position_anchor'], '40:0.0')
+ p = self.state()
+ self.assertEqual((p.position_anchor, p.device), ('40:0.0', 'diora Desktop'))
+ self.assertEqual(p.furthest_anchor, '90:0.0', 'the late write still counts as reached')
+
+ def test_resaving_an_unchanged_place_keeps_its_time_and_cannot_win(self):
+ self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy')
+ self.save('70:0.0', read_at=NOW - 2 * MIN, device='diora Desktop')
+ # The phone, idle since, saves its place again on going to the background.
+ self.now += MIN
+ r = self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy')
+ self.assertFalse(r['stored'])
+ self.assertEqual(self.state().position_anchor, '70:0.0')
+
+ def test_the_device_clock_error_is_taken_out(self):
+ # A tolino 3 hours behind: it read one minute ago by its clock, and sends now by its clock.
+ behind = 3 * 60 * MIN
+ self.save('10:0.0', read_at=NOW - 30 * MIN, device='Handy')
+ r = self.save('20:0.0', read_at=NOW - behind - MIN, sent_at=NOW - behind, device='tolino')
+ self.assertTrue(r['stored'])
+ self.assertEqual(self.state().read_at, NOW - MIN)
+
+ def test_nothing_wins_from_the_future(self):
+ self.save('10:0.0', read_at=NOW + 24 * 60 * MIN, sent_at=NOW)
+ self.assertEqual(self.state().read_at, NOW)
+ self.now += MIN
+ self.assertTrue(self.save('11:0.0', read_at=self.now, sent_at=self.now)['stored'])
+
+ def test_equal_times_take_the_new_write(self):
+ self.save('10:0.0', read_at=NOW - MIN)
+ self.assertTrue(self.save('11:0.0', read_at=NOW - MIN)['stored'])
+
+ def test_force_sets_the_place_now(self):
+ self.save('300:0.0', read_at=NOW - MIN)
+ self.assertTrue(self.save('0:0.0', frac=0.0, force=True)['stored'])
+ p = self.state()
+ self.assertEqual((p.position_anchor, p.read_at), ('0:0.0', NOW))
+ self.assertEqual(p.furthest_anchor, '300:0.0')
+
+ def test_furthest_only_grows(self):
+ self.save('80:0.0', read_at=NOW - 3 * MIN)
+ self.save('20:0.0', read_at=NOW - 2 * MIN)
+ self.save('81:0.0', read_at=NOW - 9 * MIN)
+ p = self.state()
+ self.assertEqual((p.position_anchor, p.furthest_anchor), ('20:0.0', '81:0.0'))
+
+ def test_pdfs_compare_by_fraction(self):
+ self.save('', frac=0.7, read_at=NOW - 2 * MIN)
+ self.save('', frac=0.3, read_at=NOW - MIN)
+ p = self.state()
+ self.assertEqual((p.scroll_fraction, p.furthest_fraction), (0.3, 0.7))
+
+ def test_response_is_the_state_after_the_write(self):
+ r = self.save('5:0.25', frac=0.05, read_at=NOW - MIN, device='Browser')
+ self.assertEqual(r['server_now'], NOW)
+ for key in ('scroll_fraction', 'position_anchor', 'read_at', 'device',
+ 'furthest_fraction', 'furthest_anchor', 'last_read'):
+ self.assertIn(key, r)
+ self.assertEqual(r['read_at'], NOW - MIN)
+
+ def test_implausible_times_count_as_an_old_app(self):
+ self.save('10:0.0', read_at=NOW - MIN)
+ # read_at=True or seconds instead of ms are not times: only forward, like an old app.
+ self.assertFalse(self.save('5:0.0', read_at=True)['stored'])
+ self.assertFalse(self.save('5:0.0', read_at=1_760_270_000)['stored'])
+
+ # -- apps from before the change (no read_at)
+
+ def test_old_apps_only_move_forward(self):
+ self.save('50:0.0', read_at=NOW - MIN)
+ self.assertFalse(self.save('20:0.0')['stored'])
+ self.assertTrue(self.save('60:0.0')['stored'])
+ self.assertEqual(self.state().read_at, NOW)
+
+ def test_old_apps_resaving_the_same_place_change_nothing(self):
+ self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy')
+ self.now += MIN
+ self.assertFalse(self.save('50:0.0')['stored'])
+ p = self.state()
+ self.assertEqual((p.read_at, p.device), (NOW - 5 * MIN, 'Handy'))
+
+ def test_first_write_of_an_old_app_is_stored(self):
+ self.assertTrue(self.save('0:0.0', frac=0.0)['stored'])
+
+ def test_old_force_still_works(self):
+ self.save('50:0.0', read_at=NOW - MIN)
+ self.assertTrue(self.save('1:0.0', force=True)['stored'])
+
+ # -- reading
+
+ def test_book_list_and_snapshot_carry_the_state(self):
+ self.save('7:0.5', frac=0.2, read_at=NOW - MIN, device='Browser')
+ resp = self.client.get('/books/', **self.auth)
+ b = resp.json()[0]
+ self.assertEqual(resp['X-Server-Now'], str(NOW))
+ self.assertEqual((b['position_anchor'], b['read_at'], b['device'], b['furthest_anchor']),
+ ('7:0.5', NOW - MIN, 'Browser', '7:0.5'))
+ self.assertTrue(b['last_read'].startswith('2025-10-12T'))
+ snap = self.client.get('/api/sync/?only=books', **self.auth).json()
+ p = snap['book_progress'][0]
+ self.assertEqual((p['book_id'], p['read_at'], p['device']), (self.book.id, NOW - MIN, 'Browser'))
+ self.assertIn('server_now', snap)
+
+ def test_rows_from_before_have_their_save_time_as_read_at(self):
+ p = EBookProgress.objects.create(user=self.user, book=self.book, scroll_fraction=0.4, position_anchor='9:0.0')
+ b = self.client.get('/books/', **self.auth).json()[0]
+ self.assertEqual(b['read_at'], int(p.updated_at.timestamp() * 1000))
+
+ def test_a_never_opened_book(self):
+ b = self.client.get('/books/', **self.auth).json()[0]
+ self.assertEqual((b['read_at'], b['last_read'], b['position_anchor']), (None, None, ''))
+
+
+class CoverAndPrefsTests(TestCase):
+ """Covers and the library's settings travel encrypted (docs/library.md)."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=self.user).token}'}
+ self.book = EBook.objects.create(user=self.user, meta_ct='m', meta_iv='i', data_ct='d', data_iv='i')
+
+ def post(self, url, body, **auth):
+ return self.client.post(url, json.dumps(body), content_type='application/json', **(auth or self.auth))
+
+ def test_a_cover_is_kept_and_listed(self):
+ url = f'/books/{self.book.id}/cover/'
+ self.assertEqual(self.client.get(url, **self.auth).status_code, 404)
+ self.assertFalse(self.client.get('/books/', **self.auth).json()[0]['has_cover'])
+ self.assertEqual(self.post(url, {'cover_ct': 'Q0lQSEVS', 'cover_iv': 'a1b2c3d4e5f60718293a4b5c'}).status_code, 200)
+ self.assertEqual(self.client.get(url, **self.auth).json(),
+ {'cover_ct': 'Q0lQSEVS', 'cover_iv': 'a1b2c3d4e5f60718293a4b5c'})
+ self.assertTrue(self.client.get('/books/', **self.auth).json()[0]['has_cover'])
+
+ def test_bad_or_huge_covers_are_refused(self):
+ url = f'/books/{self.book.id}/cover/'
+ self.assertEqual(self.post(url, {'cover_ct': 'x', 'cover_iv': 'not hex!'}).status_code, 400)
+ self.assertEqual(self.post(url, {'cover_ct': 'x' * 300_000, 'cover_iv': 'a1b2c3d4'}).status_code, 400)
+
+ def test_covers_are_ones_own(self):
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ bob_auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=bob).token}'}
+ url = f'/books/{self.book.id}/cover/'
+ self.assertEqual(self.post(url, {'cover_ct': 'x', 'cover_iv': 'a1b2c3d4'}, **bob_auth).status_code, 404)
+ self.assertEqual(self.client.get(url, **bob_auth).status_code, 404)
+
+ def test_prefs_are_one_encrypted_blob(self):
+ self.assertEqual(self.client.get('/books/prefs/', **self.auth).json(), {'prefs_ct': '', 'prefs_iv': ''})
+ self.post('/books/prefs/', {'prefs_ct': 'AAA', 'prefs_iv': 'a1b2c3d4e5f6'})
+ self.post('/books/prefs/', {'prefs_ct': 'BBB', 'prefs_iv': 'a1b2c3d4e5f7'})
+ self.assertEqual(self.client.get('/books/prefs/', **self.auth).json(), {'prefs_ct': 'BBB', 'prefs_iv': 'a1b2c3d4e5f7'})
+
+
+class BookRowsReadPastNoFileTests(TestCase):
+ """The list must not read a column that stands after the file (data_ct) in a book's row:
+ SQLite would walk through the whole file to get there (docs/database.md)."""
+
+ def test_the_list_queries_use_covering_indexes(self):
+ from django.contrib.auth.models import User
+ from django.db import connection
+ from django.test.utils import CaptureQueriesContext
+ from books.models import EBook, book_rows
+ user = User.objects.create_user('rows', password='x')
+ EBook.objects.create(user=user, meta_ct='m', meta_iv='i', data_ct='d' * 100000, data_iv='j', cover_iv='c', cover_ct='cc')
+ EBook.objects.create(user=user, meta_ct='m2', meta_iv='i2', data_ct='d', data_iv='j')
+ with CaptureQueriesContext(connection) as q:
+ rows = book_rows(user)
+ self.assertEqual([r['meta_ct'] for r in rows], ['m', 'm2'])
+ self.assertEqual([r['has_cover'] for r in rows], [True, False])
+ late = ('uploaded_at', 'is_read', 'cover_iv', 'cover_ct', 'data_ct', 'data_iv')
+ with connection.cursor() as cur:
+ for query in q.captured_queries:
+ sql = query['sql']
+ if any(col in sql for col in late):
+ cur.execute('EXPLAIN QUERY PLAN ' + sql.replace('%s', '?'), [user.id] if '?' in sql or '%s' in sql else [])
+ plan = ' '.join(str(r) for r in cur.fetchall())
+ self.assertIn('COVERING INDEX', plan, sql)
+
+
+class EBookFileTests(TestCase):
+ """The file lives in EBookFile; upload, data, replace-data and rekey go there."""
+
+ def setUp(self):
+ from django.contrib.auth.models import User
+ self.user = User.objects.create_user('filer', password='pw-filer-123')
+ self.client.force_login(self.user)
+
+ def test_upload_read_replace_rekey_delete(self):
+ import base64, json
+ from books.models import EBook, EBookFile
+ data = base64.b64encode(b'x' * 100).decode()
+ r = self.client.post('/books/upload/', json.dumps({'meta_ct': 'm', 'meta_iv': 'i', 'data_ct': data, 'data_iv': 'd'}),
+ content_type='application/json')
+ pk = r.json()['id']
+ self.assertEqual(EBookFile.objects.get(book_id=pk).data_ct, data)
+ self.assertEqual(self.client.get(f'/books/{pk}/data/').json(), {'data_ct': data, 'data_iv': 'd'})
+ self.client.post(f'/books/{pk}/replace-data/', json.dumps({'data_ct': 'bmV3', 'data_iv': 'd2'}), content_type='application/json')
+ self.assertEqual(self.client.get(f'/books/{pk}/data/').json()['data_ct'], 'bmV3')
+ self.client.post(f'/books/{pk}/rekey/', json.dumps({'meta_ct': 'm3', 'meta_iv': 'i3', 'data_ct': 'cmVr', 'data_iv': 'd3'}),
+ content_type='application/json')
+ self.assertEqual(EBook.objects.get(pk=pk).meta_ct, 'm3')
+ self.assertEqual(EBookFile.objects.get(book_id=pk).data_iv, 'd3')
+ self.client.post(f'/books/{pk}/delete/')
+ self.assertFalse(EBookFile.objects.filter(book_id=pk).exists())
+
+ def test_someone_elses_file_is_not_found(self):
+ from django.contrib.auth.models import User
+ from books.models import EBook
+ other = User.objects.create_user('other-filer', password='pw-other-123')
+ b = EBook.objects.create(user=other, meta_ct='m', meta_iv='i', data_ct='ZA==', data_iv='i')
+ self.assertEqual(self.client.get(f'/books/{b.id}/data/').status_code, 404)
+
+
+class StaleMetaTests(TestCase):
+ """A meta write that starts from an older meta (base_iv) is refused with the current one."""
+
+ def setUp(self):
+ from django.contrib.auth.models import User
+ from books.models import EBook
+ self.user = User.objects.create_user('metauser', password='pw-meta-123')
+ self.client.force_login(self.user)
+ self.book = EBook.objects.create(user=self.user, meta_ct='m1', meta_iv='aa', data_ct='ZA==', data_iv='i')
+
+ def post(self, body):
+ import json
+ return self.client.post(f'/books/{self.book.id}/meta/', json.dumps(body), content_type='application/json')
+
+ def test_current_base_is_taken(self):
+ self.assertEqual(self.post({'meta_ct': 'm2', 'meta_iv': 'bb', 'base_iv': 'aa'}).status_code, 200)
+ self.book.refresh_from_db()
+ self.assertEqual((self.book.meta_ct, self.book.meta_iv), ('m2', 'bb'))
+
+ def test_old_base_is_refused_with_the_current_meta(self):
+ self.post({'meta_ct': 'm2', 'meta_iv': 'bb', 'base_iv': 'aa'})
+ r = self.post({'meta_ct': 'old', 'meta_iv': 'cc', 'base_iv': 'aa'})
+ self.assertEqual(r.status_code, 409)
+ self.assertEqual((r.json()['meta_ct'], r.json()['meta_iv']), ('m2', 'bb'))
+ self.book.refresh_from_db()
+ self.assertEqual(self.book.meta_ct, 'm2')
+
+ def test_without_base_as_before(self):
+ self.assertEqual(self.post({'meta_ct': 'm3', 'meta_iv': 'dd'}).status_code, 200)
diff --git a/books/urls.py b/books/urls.py
index a9d738b..75804a8 100644
--- a/books/urls.py
+++ b/books/urls.py
@@ -5,6 +5,8 @@ urlpatterns = [
path('', views.book_list, name='book_list'),
path('upload/', views.upload_book, name='upload_book'),
path('cloud/sources/', webdav.cloud_sources, name='cloud_sources'),
+ path('cloud/inbox/', webdav.cloud_inbox, name='cloud_inbox'),
+ path('cloud//done/', webdav.cloud_done, name='cloud_done'),
path('cloud//browse/', webdav.cloud_browse, name='cloud_browse'),
path('cloud//fetch/', webdav.cloud_fetch, name='cloud_fetch'),
path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'),
@@ -12,6 +14,8 @@ urlpatterns = [
path('/delete/', views.delete_book, name='delete_book'),
path('/read/', views.set_book_read, name='set_book_read'),
path('/meta/', views.update_book_meta, name='update_book_meta'),
+ path('/cover/', views.book_cover, name='book_cover'),
+ path('prefs/', views.library_prefs, name='library_prefs'),
path('/replace-data/', views.replace_book_data, name='replace_book_data'),
path('/rekey/', views.rekey_book, name='rekey_book'),
path('/progress/', views.save_progress, name='save_book_progress'),
diff --git a/books/views.py b/books/views.py
index 121a604..299f02e 100644
--- a/books/views.py
+++ b/books/views.py
@@ -1,15 +1,18 @@
import base64
import json
import re
+import time
import xml.etree.ElementTree as ET
+from datetime import datetime, timezone as dt_timezone
import requests
from django.conf import settings
+from django.db import transaction
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
-from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
+from .models import EBook, EBookFile, EBookProgress, EBookHighlights, EBookBookmarks, LibraryPrefs, book_rows
def _require_auth(request):
@@ -247,11 +250,40 @@ def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
"""True if the new reading position is at least as far into the book as the
old one. Compares by anchor block index (precise, decoupled from layout);
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
- nb, ni = _anchor_parts(new_anchor)
- ob, oi = _anchor_parts(old_anchor)
- if nb >= 0 and ob >= 0:
- return ni >= oi if nb == ob else nb >= ob
- return new_frac >= old_frac
+ return _cmp_progress(new_anchor, new_frac, old_anchor, old_frac) >= 0
+
+
+def _cmp_progress(a_anchor, a_frac, b_anchor, b_frac):
+ """> 0 if A is further into the book than B, < 0 if behind, 0 if level."""
+ ab, ai = _anchor_parts(a_anchor)
+ bb, bi = _anchor_parts(b_anchor)
+ if ab >= 0 and bb >= 0:
+ if ab != bb:
+ return ab - bb
+ return (ai > bi) - (ai < bi)
+ return (a_frac > b_frac) - (a_frac < b_frac)
+
+
+def _now_ms():
+ return int(time.time() * 1000)
+
+
+def progress_state(p):
+ """A book's reading state as every client sees it (docs/progress-sync.md);
+ p may be None (never opened)."""
+ if p is None:
+ return {'scroll_fraction': 0.0, 'position_anchor': '', 'read_at': None, 'device': '',
+ 'furthest_fraction': 0.0, 'furthest_anchor': '', 'last_read': None}
+ read_at = p.read_at if p.read_at is not None else int(p.updated_at.timestamp() * 1000)
+ return {
+ 'scroll_fraction': p.scroll_fraction,
+ 'position_anchor': p.position_anchor,
+ 'read_at': read_at,
+ 'device': p.device,
+ 'furthest_fraction': p.furthest_fraction,
+ 'furthest_anchor': p.furthest_anchor,
+ 'last_read': datetime.fromtimestamp(read_at / 1000, tz=dt_timezone.utc).isoformat(),
+ }
@require_http_methods(['GET'])
@@ -259,26 +291,18 @@ def book_list(request):
err = _require_auth(request)
if err:
return err
- books = list(
- request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')
- )
- for b in books:
- b['uploaded_at'] = b['uploaded_at'].isoformat()
- # Include saved scroll_fraction for each book
- progress_map = {
- p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor)
- for p in EBookProgress.objects.filter(user=request.user)
- }
+ books = book_rows(request.user)
+ progress_map = {p.book_id: p for p in EBookProgress.objects.filter(user=request.user)}
highlighted_ids = set(
EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True)
)
for b in books:
- prog = progress_map.get(b['id'])
- b['scroll_fraction'] = prog[0] if prog else 0.0
- b['last_read'] = prog[1].isoformat() if prog else None
- b['position_anchor'] = prog[2] if prog else ''
+ b.update(progress_state(progress_map.get(b['id'])))
b['has_highlights'] = b['id'] in highlighted_ids
- return JsonResponse(books, safe=False)
+ response = JsonResponse(books, safe=False)
+ # The list is a bare array (old clients expect that): the server clock goes in a header.
+ response['X-Server-Now'] = str(_now_ms())
+ return response
@csrf_exempt
@@ -326,12 +350,77 @@ def update_book_meta(request, pk):
if not meta_ct or not meta_iv:
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
+ # base_iv: the IV of the meta the client changed. Another one now (changed elsewhere
+ # since): refused with the current meta, for the client to apply its change to that.
+ # A queued write from a device with an old list (a title or folder set meanwhile)
+ # used to put the old meta back. Clients that don't send base_iv write as before.
+ base_iv = body.get('base_iv')
+ if base_iv and base_iv != book.meta_iv:
+ return JsonResponse({'error': 'stale', 'meta_ct': book.meta_ct, 'meta_iv': book.meta_iv}, status=409)
+
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.save(update_fields=['meta_ct', 'meta_iv'])
return JsonResponse({'ok': True})
+MAX_COVER = 200_000 # characters of base64: a 300×450 JPEG is far below
+MAX_PREFS = 100_000
+
+
+def _hex_iv(value):
+ return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value)
+
+
+@csrf_exempt
+@require_http_methods(['GET', 'POST'])
+def book_cover(request, pk):
+ """A book's cover, encrypted on the device with the user's key (docs/library.md):
+ GET {cover_ct, cover_iv} (404 without one), POST the same to set it."""
+ err = _require_auth(request)
+ if err:
+ return err
+ book = EBook.objects.filter(pk=pk, user=request.user).only('id', 'cover_ct', 'cover_iv').first()
+ if book is None:
+ return JsonResponse({'error': 'not found'}, status=404)
+ if request.method == 'GET':
+ if not book.cover_ct:
+ return JsonResponse({'error': 'no cover'}, status=404)
+ return JsonResponse({'cover_ct': book.cover_ct, 'cover_iv': book.cover_iv})
+ try:
+ body = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ ct, iv = body.get('cover_ct'), body.get('cover_iv')
+ if not isinstance(ct, str) or not ct or len(ct) > MAX_COVER or not _hex_iv(iv):
+ return JsonResponse({'error': 'cover_ct, cover_iv required (cover at most 150 KB)'}, status=400)
+ book.cover_ct, book.cover_iv = ct, iv
+ book.save(update_fields=['cover_ct', 'cover_iv'])
+ return JsonResponse({'ok': True})
+
+
+@csrf_exempt
+@require_http_methods(['GET', 'POST'])
+def library_prefs(request):
+ """The library's settings, encrypted with the user's key ({folder_order: [...]}):
+ GET {prefs_ct, prefs_iv} ('' while there are none), POST the same, replacing them."""
+ err = _require_auth(request)
+ if err:
+ return err
+ if request.method == 'GET':
+ p = LibraryPrefs.objects.filter(user=request.user).first()
+ return JsonResponse({'prefs_ct': p.prefs_ct if p else '', 'prefs_iv': p.prefs_iv if p else ''})
+ try:
+ body = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ ct, iv = body.get('prefs_ct'), body.get('prefs_iv')
+ if not isinstance(ct, str) or not ct or len(ct) > MAX_PREFS or not _hex_iv(iv):
+ return JsonResponse({'error': 'prefs_ct, prefs_iv required'}, status=400)
+ LibraryPrefs.objects.update_or_create(user=request.user, defaults={'prefs_ct': ct, 'prefs_iv': iv})
+ return JsonResponse({'ok': True})
+
+
@csrf_exempt
@require_http_methods(['POST'])
def upload_book(request):
@@ -360,16 +449,17 @@ def upload_book(request):
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
if raw_size > max_bytes:
- return JsonResponse({'error': 'file too large (max 50 MB)'}, status=400)
+ return JsonResponse({'error': f'file too large (max {settings.EBOOK_MAX_BYTES // (1024 * 1024)} MB)'}, status=400)
- book = EBook.objects.create(
- user=request.user,
- meta_ct=meta_ct,
- meta_iv=meta_iv,
- data_ct=data_ct,
- data_iv=data_iv,
- )
- EBookProgress.objects.create(user=request.user, book=book)
+ with transaction.atomic():
+ book = EBook.objects.create(
+ user=request.user,
+ meta_ct=meta_ct,
+ meta_iv=meta_iv,
+ data_ct=data_ct,
+ data_iv=data_iv,
+ )
+ EBookProgress.objects.create(user=request.user, book=book)
return JsonResponse({'ok': True, 'id': book.id})
@@ -379,12 +469,10 @@ def get_book_data(request, pk):
if err:
return err
- try:
- book = EBook.objects.get(pk=pk, user=request.user)
- except EBook.DoesNotExist:
+ f = EBookFile.objects.filter(book_id=pk, book__user=request.user).first()
+ if f is None:
return JsonResponse({'error': 'not found'}, status=404)
-
- return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
+ return JsonResponse({'data_ct': f.data_ct, 'data_iv': f.data_iv})
@csrf_exempt
@@ -412,14 +500,12 @@ def replace_book_data(request, pk):
if not all([data_ct, data_iv]):
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
- update_fields = ['data_ct', 'data_iv']
- book.data_ct = data_ct
- book.data_iv = data_iv
- if meta_ct and meta_iv:
- book.meta_ct = meta_ct
- book.meta_iv = meta_iv
- update_fields += ['meta_ct', 'meta_iv']
- book.save(update_fields=update_fields)
+ with transaction.atomic():
+ EBookFile.objects.update_or_create(book=book, defaults={'data_ct': data_ct, 'data_iv': data_iv})
+ if meta_ct and meta_iv:
+ book.meta_ct = meta_ct
+ book.meta_iv = meta_iv
+ book.save(update_fields=['meta_ct', 'meta_iv'])
return JsonResponse({'ok': True})
@@ -448,11 +534,11 @@ def rekey_book(request, pk):
if not all([meta_ct, meta_iv, data_ct, data_iv]):
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
- book.meta_ct = meta_ct
- book.meta_iv = meta_iv
- book.data_ct = data_ct
- book.data_iv = data_iv
- book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv'])
+ with transaction.atomic():
+ EBookFile.objects.update_or_create(book=book, defaults={'data_ct': data_ct, 'data_iv': data_iv})
+ book.meta_ct = meta_ct
+ book.meta_iv = meta_iv
+ book.save(update_fields=['meta_ct', 'meta_iv'])
return JsonResponse({'ok': True})
@@ -497,22 +583,50 @@ def save_progress(request, pk):
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
position_anchor = raw_anchor
- force = bool(body.get('force', False))
+ force = body.get('force') is True
+ device = body.get('device') if isinstance(body.get('device'), str) else ''
+ now = _now_ms()
+ read_at = _client_ms(body.get('read_at'))
+ sent_at = _client_ms(body.get('sent_at'))
- progress, _ = EBookProgress.objects.get_or_create(
- user=request.user,
- book=book,
- )
- # Always advance to the furthest-read position (by anchor block index, so a
- # transiently wrong scroll_fraction can't freeze the position) unless the
- # client explicitly forces a reset (e.g. "start over" button in the reader).
- if force or _progress_is_further(position_anchor, scroll_fraction,
- progress.position_anchor, progress.scroll_fraction):
- progress.scroll_fraction = scroll_fraction
- progress.position_anchor = position_anchor
- progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
+ with transaction.atomic():
+ progress, created = EBookProgress.objects.select_for_update().get_or_create(user=request.user, book=book)
+ if created:
+ progress.read_at = None
+ if force:
+ when, store = now, True
+ elif read_at is not None:
+ # The device's clock may be off: move its time onto ours by the
+ # difference at sending, and nothing counts as later than now.
+ when = read_at + (now - sent_at) if sent_at is not None else read_at
+ when = min(when, now)
+ store = progress.read_at is None or when >= progress.read_at
+ else:
+ # An app from before 2026-10-12: only forward, as it used to be — but
+ # strictly, so re-saving the same place changes nothing.
+ when = now
+ store = created or _cmp_progress(position_anchor, scroll_fraction,
+ progress.position_anchor, progress.scroll_fraction) > 0
+ if store:
+ progress.scroll_fraction = scroll_fraction
+ progress.position_anchor = position_anchor
+ progress.read_at = when
+ progress.device = device[:40]
+ if _cmp_progress(position_anchor, scroll_fraction,
+ progress.furthest_anchor, progress.furthest_fraction) > 0 or created:
+ progress.furthest_fraction = scroll_fraction
+ progress.furthest_anchor = position_anchor
+ progress.save()
- return JsonResponse({'ok': True})
+ return JsonResponse({'ok': True, 'stored': store, 'server_now': now, **progress_state(progress)})
+
+
+def _client_ms(value):
+ """A time from a client (ms since epoch) or None if it isn't a plausible one."""
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return None
+ value = int(value)
+ return value if 10 ** 12 <= value < 10 ** 14 else None
@csrf_exempt
diff --git a/books/webdav.py b/books/webdav.py
index 11ed64f..d43b72e 100644
--- a/books/webdav.py
+++ b/books/webdav.py
@@ -12,12 +12,16 @@ browser and only then POSTed to /books/upload/ as ciphertext, exactly like a
local upload — nothing readable is ever stored server-side.
"""
+import json
+from datetime import timedelta
+
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
+from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
-from accounts.models import WebDAVSource
+from accounts.models import WebDAVSeen, WebDAVSource
from accounts.webdav import WebDAVError, WebDAVInputError, list_directory, fetch_file
from .views import _require_auth
@@ -34,11 +38,94 @@ def cloud_sources(request):
sources = request.user.webdav_sources.all()
return JsonResponse({'sources': [
- {'id': s.id, 'label': s.label, 'host': s.base_url}
+ {'id': s.id, 'label': s.label, 'host': s.base_url, 'inbox': s.inbox}
for s in sources
]})
+# ---- "Eingang" (docs/library.md): new books of a source into the library by themselves ----
+
+INBOX_DEPTH = 3 # sub-folders looked into
+INBOX_MAX = 20 # files handed out per call
+INBOX_CLAIM = timedelta(minutes=10)
+
+
+def _inbox_files(source):
+ """The source's book files, its sub-folders too (INBOX_DEPTH deep)."""
+ out, todo = [], [('', 0)]
+ while todo:
+ path, depth = todo.pop(0)
+ for e in list_directory(source, path):
+ if e['is_dir'] and depth < INBOX_DEPTH:
+ todo.append((e['path'], depth + 1))
+ elif e['is_book']:
+ out.append(e)
+ return out
+
+
+@require_http_methods(['GET'])
+def cloud_inbox(request):
+ """The files of the user's "Eingang" sources not taken into the library yet, claimed for
+ this device for ten minutes (another device asking meanwhile doesn't get them):
+ {files: [{source, label, path, name, size, modified}], errors: [{source, label, error}]}.
+ A file that changed (other size) counts as new again."""
+ unauthorized = _require_auth(request)
+ if unauthorized:
+ return unauthorized
+ now = timezone.now()
+ files, errors = [], []
+ for source in request.user.webdav_sources.filter(inbox=True):
+ try:
+ entries = _inbox_files(source)
+ except (WebDAVError, WebDAVInputError) as exc:
+ errors.append({'source': source.id, 'label': source.label, 'error': str(exc)})
+ continue
+ seen = {s.path: s for s in source.seen.all()}
+ for e in entries:
+ if len(files) >= INBOX_MAX:
+ break
+ s = seen.get(e['path'])
+ if s and s.size == e['size']:
+ if s.done_at or (s.claimed_at and now - s.claimed_at < INBOX_CLAIM):
+ continue
+ WebDAVSeen.objects.update_or_create(
+ source=source, path=e['path'][:1000],
+ defaults={'size': e['size'], 'modified': e['modified'][:100], 'claimed_at': now,
+ 'done_at': None, 'result': ''})
+ files.append({'source': source.id, 'label': source.label, 'path': e['path'],
+ 'name': e['name'].rsplit('/', 1)[-1], 'size': e['size'], 'modified': e['modified']})
+ source.last_used_at = now
+ source.save(update_fields=['last_used_at'])
+ return JsonResponse({'files': files, 'errors': errors})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def cloud_done(request, pk):
+ """{path, result}: a device finished with an "Eingang" file — imported, known (already in the
+ library), too_big: not offered again; failed: offered again after the claim ran out."""
+ unauthorized = _require_auth(request)
+ if unauthorized:
+ return unauthorized
+ source = _get_source(request, pk)
+ if not source:
+ return JsonResponse({'error': 'unknown source'}, status=404)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ result = body.get('result') if isinstance(body, dict) else None
+ if result not in ('imported', 'known', 'too_big', 'failed'):
+ return JsonResponse({'error': 'result: imported, known, too_big or failed'}, status=400)
+ seen = WebDAVSeen.objects.filter(source=source, path=str(body.get('path', ''))[:1000]).first()
+ if not seen:
+ return JsonResponse({'error': 'unknown file'}, status=404)
+ seen.result = result
+ seen.done_at = None if result == 'failed' else timezone.now()
+ seen.save(update_fields=['result', 'done_at'])
+ return JsonResponse({'ok': True})
+
+
@require_http_methods(['GET'])
def cloud_browse(request, pk):
unauthorized = _require_auth(request)
diff --git a/diora/__init__.py b/diora/__init__.py
index e69de29..102c0ca 100644
--- a/diora/__init__.py
+++ b/diora/__init__.py
@@ -0,0 +1,2 @@
+# SQLite in WAL mode for every connection (diora/sqlite.py).
+from . import sqlite # noqa: E402,F401
diff --git a/diora/search.py b/diora/search.py
new file mode 100644
index 0000000..2bdb5c7
--- /dev/null
+++ b/diora/search.py
@@ -0,0 +1,101 @@
+"""The search across diora (docs/search.md): one query, what the server knows of every area —
+podcasts and audiobooks one follows, podcast episodes (the full-text index, podcasts/discover.py),
+news and blog sources, their articles, saved radio stations. The books aren't here: their titles
+are encrypted, the page searches them itself (static/js/search.js).
+
+Every word of the query has to be there, as the start of a word ("plattf" finds "Plattform");
+case and diacritics don't count."""
+from django.db.models import Q
+from django.http import JsonResponse
+from django.views.decorators.http import require_http_methods
+
+from feeds.models import Item, Source
+from podcasts import discover
+from podcasts.models import PodcastFeed
+from radio.models import SavedStation
+
+LIMIT = 8 # per area; the area's own view has the rest
+ARTICLE_SCAN = 400 # articles looked at after the database's rough match
+
+
+def _words(q):
+ return [w for w in discover._words(q) if w][:8]
+
+
+def _matches(words, *texts):
+ """Every word as the start of a word in the texts ("plattf" finds "Plattform")."""
+ have = discover._words(' '.join(t or '' for t in texts))
+ return bool(words) and all(any(h.startswith(w) for h in have) for w in words)
+
+
+def _rank(words, title):
+ """Title starting with the query first, then title words, then the rest."""
+ t = discover._words(title)
+ if t[:len(words)] == words or (t and t[0].startswith(words[0])):
+ return 0
+ return 1 if all(any(h.startswith(w) for h in t) for w in words) else 2
+
+
+def _feeds(user, words):
+ out = {'podcasts': [], 'audiobooks': []}
+ rows = PodcastFeed.objects.filter(user=user).values('id', 'title', 'author', 'artwork_url', 'kind', 'rss_url',
+ 'favorite', 'new_episodes')
+ found = [f for f in rows if _matches(words, f['title'], f['author'])]
+ found.sort(key=lambda f: (_rank(words, f['title']), (f['title'] or '').lower()))
+ for f in found:
+ key = 'audiobooks' if f['kind'] == PodcastFeed.AUDIOBOOK else 'podcasts'
+ out[key].append(f)
+ return {k: v[:LIMIT] for k, v in out.items()} | {'podcasts_total': len(out['podcasts']),
+ 'audiobooks_total': len(out['audiobooks'])}
+
+
+def _sources(user, words):
+ rows = Source.objects.filter(user=user).values('id', 'title', 'url', 'kind', 'icon_url', 'category')
+ found = [s for s in rows if _matches(words, s['title'], s['url'], s['category'])]
+ found.sort(key=lambda s: (_rank(words, s['title']), (s['title'] or '').lower()))
+ return found[:LIMIT]
+
+
+def _articles(user, words):
+ """Articles whose title or author has every word, the newest first. The database narrows by
+ the longest word (LIKE, as case-blind as SQLite is), Python decides."""
+ longest = max(words, key=len)
+ rough = Q(title__icontains=longest) | Q(author__icontains=longest)
+ if longest != longest.capitalize():
+ rough |= Q(title__contains=longest.capitalize())
+ rows = (Item.objects.filter(user=user, gone=False).filter(rough).order_by('-published')
+ .values('id', 'title', 'author', 'kind', 'published', 'image_url', 'read_at', 'source__title')[:ARTICLE_SCAN])
+ found = [i for i in rows if _matches(words, i['title'], i['author'])]
+ return [{'id': i['id'], 'title': i['title'], 'author': i['author'], 'kind': i['kind'],
+ 'published': i['published'].isoformat() if i['published'] else None, 'image_url': i['image_url'],
+ 'read': i['read_at'] is not None, 'source': i['source__title']} for i in found[:LIMIT * 2]], len(found)
+
+
+def _stations(user, words):
+ rows = SavedStation.objects.filter(user=user).values('id', 'name', 'url', 'tags', 'country', 'favicon_url',
+ 'is_favorite')
+ found = [s for s in rows if _matches(words, s['name'], s['tags'], s['country'])]
+ found.sort(key=lambda s: (_rank(words, s['name']), not s['is_favorite'], s['name'].lower()))
+ return found[:LIMIT]
+
+
+def search(user, q):
+ words = _words(q)
+ if not words or len(''.join(words)) < 2:
+ return {'q': q, 'empty': True}
+ out = {'q': q}
+ out.update(_feeds(user, words))
+ ids = discover.search_ids(user, q, limit=LIMIT * 2)
+ out['episodes'] = discover._rows(user, ids)
+ out['sources'] = _sources(user, words)
+ out['articles'], out['articles_total'] = _articles(user, words)
+ out['stations'] = _stations(user, words)
+ return out
+
+
+@require_http_methods(['GET'])
+def search_view(request):
+ """?q=…: what matches in every area (see the module's text)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ return JsonResponse(search(request.user, request.GET.get('q', '')[:200]))
diff --git a/diora/settings.py b/diora/settings.py
index ffbe165..e0c9112 100644
--- a/diora/settings.py
+++ b/diora/settings.py
@@ -31,12 +31,14 @@ INSTALLED_APPS = [
'books',
'gpodder',
'tts',
+ 'feeds',
+ 'projects',
]
-EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
+EBOOK_MAX_BYTES = 100 * 1024 * 1024 # 100 MB (big scanned PDFs, complete editions)
-# Encrypted uploads are base64-encoded (~33% overhead) so allow ~75 MB body
-DATA_UPLOAD_MAX_MEMORY_SIZE = 75 * 1024 * 1024
+# Encrypted uploads are base64-encoded (~33% overhead) so allow ~140 MB body
+DATA_UPLOAD_MAX_MEMORY_SIZE = 140 * 1024 * 1024
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
@@ -76,7 +78,9 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data' / os.environ.get('DIORA_DB_NAME', 'db.sqlite3'),
- 'OPTIONS': {'timeout': 20},
+ # WAL (diora/sqlite.py); a transaction takes the write lock when it begins, so one that
+ # reads first never fails on upgrading it (WAL's busy timeout can't help there).
+ 'OPTIONS': {'timeout': 20, 'transaction_mode': 'IMMEDIATE'},
}
}
@@ -89,7 +93,7 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = 'de'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
diff --git a/diora/sqlite.py b/diora/sqlite.py
new file mode 100644
index 0000000..873117d
--- /dev/null
+++ b/diora/sqlite.py
@@ -0,0 +1,23 @@
+"""SQLite in write-ahead-log mode (docs/database.md): readers no longer wait for a writer.
+
+In the old rollback-journal mode a long write (the podcast refresh every 15 minutes, a book of
+30 MB going in) kept every reader out until it was done, and requests failed with "database is
+locked" — even GET /books/. WAL is set once per database file and stays; synchronous=NORMAL is
+the safe pairing for it. A connection that can't switch (another one holds the file) just goes
+on as it is; the next one switches.
+"""
+from django.db.backends.signals import connection_created
+
+
+def _sqlite_wal(sender, connection, **kwargs):
+ if connection.vendor != 'sqlite':
+ return
+ with connection.cursor() as cursor:
+ try:
+ cursor.execute('PRAGMA journal_mode=WAL')
+ except Exception: # noqa: BLE001 - locked right now; another connection will do it
+ pass
+ cursor.execute('PRAGMA synchronous=NORMAL')
+
+
+connection_created.connect(_sqlite_wal, dispatch_uid='diora_sqlite_wal')
diff --git a/diora/test_search.py b/diora/test_search.py
new file mode 100644
index 0000000..863a8a3
--- /dev/null
+++ b/diora/test_search.py
@@ -0,0 +1,62 @@
+"""The search across diora (diora/search.py)."""
+from datetime import datetime, timezone
+
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from feeds.models import Item, Source
+from podcasts import discover
+from podcasts.models import PodcastEpisode, PodcastFeed
+from radio.models import SavedStation
+
+
+class SearchTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username='sucher', password='pw12345678')
+ self.client.force_login(self.user)
+ other = User.objects.create_user(username='andere', password='pw12345678')
+ when = datetime(2026, 3, 1, tzinfo=timezone.utc)
+ self.pod = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/p', title='Ökonomie heute',
+ author='Peter Decker')
+ self.book = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Das Kapital',
+ author='Karl Marx', kind=PodcastFeed.AUDIOBOOK)
+ PodcastFeed.objects.create(user=other, rss_url='https://example.org/p', title='Ökonomie heute')
+ self.ep = PodcastEpisode.objects.create(feed=self.pod, guid='e1', title='Die Ökonomie der Plattformen',
+ audio_url='https://example.org/e1.mp3', pub_date=when)
+ PodcastEpisode.objects.create(feed=self.book, guid='k1', title='Kapitel 1: Die Ware',
+ audio_url='https://example.org/k1.mp3', pub_date=when)
+ discover.index_new()
+ blog = Source.objects.create(user=self.user, url='https://blog.example/feed', title='Plattform-Blog', kind=Source.BLOG)
+ Item.objects.create(source=blog, user=self.user, kind=Source.BLOG, guid_hash='a', title='Plattformarbeit und Streik',
+ published=when)
+ Item.objects.create(source=blog, user=self.user, kind=Source.BLOG, guid_hash='b', title='Gartenarbeit', published=when)
+ Item.objects.create(source=blog, user=self.user, kind=Source.BLOG, guid_hash='c', title='Plattformen, alt',
+ published=when, gone=True)
+ SavedStation.objects.create(user=self.user, name='Radio Plattform', url='https://radio.example/s', tags='jazz')
+
+ def find(self, q):
+ return self.client.get('/search/', {'q': q}).json()
+
+ def test_every_area(self):
+ r = self.find('plattf')
+ self.assertEqual([e['id'] for e in r['episodes']], [self.ep.id])
+ self.assertEqual([a['title'] for a in r['articles']], ['Plattformarbeit und Streik'])
+ self.assertEqual([s['title'] for s in r['sources']], ['Plattform-Blog'])
+ self.assertEqual([s['name'] for s in r['stations']], ['Radio Plattform'])
+
+ def test_podcasts_and_audiobooks_apart_case_and_accents_blind(self):
+ r = self.find('okonomie')
+ self.assertEqual([f['id'] for f in r['podcasts']], [self.pod.id])
+ self.assertEqual(r['audiobooks'], [])
+ r = self.find('marx kap')
+ self.assertEqual([f['id'] for f in r['audiobooks']], [self.book.id])
+ self.assertEqual(r['episodes'], [], 'an audiobook\'s chapters are no podcast episodes')
+
+ def test_every_word_and_nothing_for_too_little(self):
+ self.assertEqual(self.find('plattform garten')['articles'], [])
+ self.assertTrue(self.find('a')['empty'])
+ self.assertEqual(self.client.get('/search/', {'q': 'x'}).status_code, 200)
+
+ def test_only_signed_in(self):
+ self.client.logout()
+ self.assertEqual(self.client.get('/search/', {'q': 'plattform'}).status_code, 401)
diff --git a/diora/urls.py b/diora/urls.py
index 283cf2c..68dceb6 100644
--- a/diora/urls.py
+++ b/diora/urls.py
@@ -5,15 +5,19 @@ from django.urls import path, include
from django.views.static import serve as serve_static
from accounts.sync import sync_snapshot
+from diora.search import search_view
urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')),
path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')),
+ path('feeds/', include('feeds.urls')),
+ path('projects/', include('projects.urls')),
path('tts/', include('tts.urls')),
path('api/2/', include('gpodder.urls')),
path('api/sync/', sync_snapshot, name='api_sync'),
+ path('search/', search_view, name='search'),
# Served at the root (not /static/js/sw.js) so its default scope covers
# the whole app — a service worker's scope is limited to its own script's
# directory unless the server sends Service-Worker-Allowed, so registering
diff --git a/docs/ai.md b/docs/ai.md
new file mode 100644
index 0000000..3ed9ece
--- /dev/null
+++ b/docs/ai.md
@@ -0,0 +1,57 @@
+# KI: Claude schlägt vor, der Nutzer entscheidet
+
+Since 2026-10-24. Organisation help with Claude (Anthropic) across diora: books and magazines
+first; podcasts, news/blogs and projects follow (same building blocks).
+
+## The key: the user's own, end-to-end encrypted, only on the devices
+
+Two providers, chosen in the settings (decided 2026-10-25): **Anthropic** (the official SDK) or
+**OpenRouter** (the same Claude model, `anthropic/claude-opus-5.5`). OpenRouter's
+Anthropic-compatible endpoint refuses the headers a browser call needs (`anthropic-version` …,
+checked by a CORS preflight), so diora talks to OpenRouter's chat completions instead: plain
+`fetch`, `response_format` json_schema (strict), `reasoning.effort`; no fallback on refusals, and
+OpenRouter sees what is sent too. The vault keeps `{provider, anthropic_key, openrouter_key}`
+(an old `{anthropic_key}` still reads as Anthropic); a key is checked before it is kept
+(Anthropic: the model's info; OpenRouter: `GET /api/v1/key`).
+
+Decided with the user: the API key is entered in the app ("KI-Assistent", the ✦ of a
+feature or the app menu) and stored like the books — encrypted with the book key on the device,
+kept by the server as a blob it can't read:
+
+- `GET/POST /accounts/vault//` (`accounts.UserVault`, name `ai`): `{ct, iv}`; `{ct: ''}`
+ removes it. Its own store, not the library prefs (`/books/prefs/` is rewritten whole when
+ the folder order is saved).
+- The devices decrypt it into memory only and call Claude **directly** from the page
+ (browser, the apps' WebView, the desktop). The diora server sees neither the key nor what is
+ sent; Anthropic sees what a feature needs (titles, authors, folder names …). Anthropic bills
+ the user.
+- Without the book key on a device (or after a password change, which changes the book key)
+ the key can't be read: the settings ask for it again. Not on diora Reader (e-readers).
+- Server-side jobs (in the background, without an app open) were left out on purpose: they
+ would need the key on the server. The project Radar has its own server key (docs/projects.md).
+
+## Building blocks (`static/js/ai.js`, an ES module)
+
+- The official SDK, `@anthropic-ai/sdk` 0.133.0, bundled for the browser with esbuild 0.28.2:
+ `static/vendor/anthropic-sdk-0.133.0.mjs` (licence beside it). `dangerouslyAllowBrowser`:
+ the key is the user's own. To update: `npm install @anthropic-ai/sdk@ esbuild` in an empty
+ folder, `esbuild entry.mjs --bundle --format=esm --platform=browser --target=es2020 --minify`
+ with `export { default } from '@anthropic-ai/sdk'`, and change the import in ai.js.
+- `DioraAI.ask({system, prompt, schema, effort})`: model `claude-opus-5-5`, structured output
+ (`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
+ (beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
+ answers become plain German messages.
+- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
+ changes until the user ticks and presses "Übernehmen".
+- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
+ with Android's back key; ⚙ in each sheet opens the settings.
+
+## Features
+
+| area | where | what Claude gets | what it suggests |
+|---|---|---|---|
+| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
+| Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) |
+| Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong |
+
+Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
diff --git a/docs/audiobooks.md b/docs/audiobooks.md
new file mode 100644
index 0000000..00d57af
--- /dev/null
+++ b/docs/audiobooks.md
@@ -0,0 +1,112 @@
+# Audiobooks (Hörbücher & Hörspiele)
+
+A podcast subscription can be marked as an audiobook. It then leaves the
+podcast world (no "Neu", no queue automation, not in the podcast lists) and
+shows up in the library ("Bücher") next to the e-books, where it is listened to
+like a book: its episodes are chapters in order, "Weiterhören" goes on where
+the last chapter stopped, the whole book has one progress, places can be kept
+as bookmarks with a note, and the sleep timer can stop at the end of a chapter.
+
+## Server (diora-web)
+
+### The mark
+
+`PodcastFeed.kind`: `"podcast"` (default) or `"audiobook"`. A feed setting like
+the others: in `state.feeds[]` (`/podcasts/state/`), set with
+`POST /podcasts/feeds//settings/ {"kind": "audiobook"}`.
+
+Marked as an audiobook:
+- its episodes leave "Neu" (dismissed, not played); new ones never get there
+ and are never queued, whatever `new_episodes` says;
+- it is left out of "Folgen" (`/podcasts/episodes/` without `feed_id`);
+- nothing else changes: places, played marks, downloads, settings stay.
+ Episodes of it already in the queue stay there until played or removed.
+
+### Chapters and progress
+
+Chapter order: `episode_number` (if set), then `pub_date`, then the title in
+natural order ("Kapitel 2" before "Kapitel 10"), then id.
+
+`GET /podcasts/audiobooks/` → `{"books": [book, …]}`, the last listened first:
+
+```
+book = {
+ "feed_id", "title", "author", "artwork_url",
+ "chapters": 24, "done": 6, // played chapters
+ "total_seconds": 32400, "heard_seconds": 11400,
+ "next": {"episode_id", "index", "position_seconds"} | null, // null: finished
+ "last_played_at": ISO | null
+}
+```
+
+`heard_seconds` = played chapters' durations + the places in the others.
+Durations the feed doesn't have count as 0 until a player reports them
+(`progress/save` with `duration_seconds`, see youtube.md).
+
+`GET /podcasts/audiobooks//` → `{"book": book, "chapters": [ch, …],
+"bookmarks": [bm, …]}` with
+
+```
+ch = { // the queue entry's shape, so it can be handed to the player as it is
+ "episode__id", "episode__title", "episode__audio_url", "episode__duration_seconds",
+ "episode__artwork_url", "episode__feed__id", "episode__feed__title",
+ "episode__feed__artwork_url", "episode__pub_date", "position_seconds", "played",
+ "index"
+}
+bm = {"id", "episode_id", "position_ms", "note", "created_at"} // in chapter order, then place
+```
+
+### Bookmarks
+
+- `POST /podcasts/bookmarks/ {"episode_id", "position_ms", "note"}` → `{"ok", "bookmark": bm}`
+- `POST /podcasts/bookmarks// {"note"}` → `{"ok", "bookmark": bm}`
+- `POST /podcasts/bookmarks//delete/` → `{"ok"}`
+
+Any episode can have bookmarks; the library shows those of audiobooks.
+
+## The player (both hosts: podcasts-web.js and Android)
+
+One new host call, next to `podcastPlayEpisode`:
+
+`podcastPlayBook(json)` with `json = {"feed_id", "chapters": [ch, …], "index", "position_ms"}`
+(`position_ms` optional: else the chapter's own place)
+
+- plays `chapters[index]` **without putting anything in the queue**;
+- the book is what plays next: when a chapter ends it counts as played and the
+ next chapter follows from its own place; after the last one playing stops;
+ "next" (⏭, media keys) goes to the next chapter without marking the one left
+ as played;
+- playing anything else (a queue episode, the radio's own handover aside) ends
+ the book context; the book context survives a restart of the page/app;
+- the sleep timer needs nothing new: "end of chapter" is `podcastSleep(-1)`
+ (end of the episode) or, for an episode with chapters of its own, the minutes
+ until that chapter's end (`podcastSleep(ms / 60000)`, fractions are fine).
+
+`playerState()` (unchanged) tells the library which chapter plays where.
+
+## The library (diora-web `static/js/audiobooks.js`, everywhere)
+
+One file for the web page, the desktop app and the Android app (like
+feeds.js; full flavour only, it needs the podcast host). It draws into
+`#tab-books`, above `#book-list`, in its own container `#audiobook-shelf`:
+
+- "Weiterhören": the audiobook listened to last (unless finished);
+- "Hörbücher": a card per audiobook — picture, title, author,
+ "3 h 10 von 9 h · Kapitel 7/24" and a progress bar;
+- "+ Hörbuch": pick one of the podcast subscriptions to mark as an audiobook;
+- the book view: picture, progress, ▶ Weiterhören / Von vorn, the chapter list
+ (tap: play it from its place; played ones ✓), bookmarks (tap: play from there;
+ note editable; ✕), "+ Lesezeichen" (at the place where this book plays now),
+ "Schlummer: Kapitelende", and ⋮ → "Wieder als Podcast", "Alles als ungehört".
+
+It talks to the server with fetch and to the player through the host
+(`window.DioraNative` on Android, `window.DioraPodcastHost` elsewhere):
+`podcastState`, `podcastFeedSettings`, `podcastPlayBook`, `playerState`,
+`podcastSleep`, `podcastMarkPlayedAll`.
+
+## The podcast UI (native-podcast.js, diora-android)
+
+- feed settings sheet: "Als Hörbuch" (sets `kind`);
+- audiobook feeds left out of Abos, Neu, Folgen, search results of the
+ subscriptions; a hint "In Bücher → Hörbücher" where one was moved;
+- the player shows a book chapter as "Kapitel 7/24 · " when it plays one.
diff --git a/docs/database.md b/docs/database.md
new file mode 100644
index 0000000..3484c04
--- /dev/null
+++ b/docs/database.md
@@ -0,0 +1,47 @@
+# The database
+
+One SQLite file, `data/db.sqlite3` (on the server `/home/marwin/diora-prod-data`, mounted into
+`diora-prod` at `/app/data`). The books live in it too, encrypted, so it is large (2.5 GB in
+October 2026) and some writes are big (a scanned book of 30 MB).
+
+## Write-ahead log (since 2026-10-19)
+
+`diora/sqlite.py` puts every connection in WAL mode (`PRAGMA journal_mode=WAL`, set once per
+file, it stays) with `synchronous=NORMAL`; `settings.DATABASES` has `transaction_mode:
+IMMEDIATE`, so a transaction takes the write lock when it begins.
+
+Why: in the rollback-journal mode before, a long write kept all readers out. The podcast
+refresh (cron, every 15 minutes) and big uploads made requests fail with "database is
+locked", GET /books/ among them — the apps then saw no new books and no changed titles.
+With WAL, readers read on while one writer writes; writers still take turns (20 s timeout).
+
+Next to `db.sqlite3` there are now `db.sqlite3-wal` and `db.sqlite3-shm`. They belong to the
+database: a copy of `db.sqlite3` alone may miss the latest writes. To copy it safely:
+
+ docker exec diora-prod python -c "import sqlite3; s=sqlite3.connect('data/db.sqlite3'); d=sqlite3.connect('data/backup.sqlite3'); s.backup(d)"
+
+or stop the container first. Back to the old mode, if ever needed:
+`PRAGMA journal_mode=DELETE` (with no other connection open) and remove `diora/sqlite.py`'s hook.
+
+## The book list never reads past the file
+
+A book's row is `user, meta_ct, meta_iv, data_ct (the file), data_iv, uploaded_at, is_read,
+cover_ct, cover_iv`. SQLite stores a big value in a chain of overflow pages, and to reach a
+column *after* it, it walks the whole chain: listing 427 books read gigabytes and took 37 s
+(the desktop app gives up after 10 s, so it never synced). Since then `books.models.book_rows()`
+reads `id, meta_ct, meta_iv` from the rows (they stand before the file) and `uploaded_at,
+is_read` / whether there is a cover from the covering indexes `ebook_list_idx` and
+`ebook_cover_idx` — no list query touches a file. `books/tests.py` checks the query plans.
+
+## The files in a table of their own (since 2026-10-19)
+
+`books.models.EBookFile` (one row per book, `book_id` its key) holds `data_ct`/`data_iv`;
+`EBook` has only the small fields. Before, every change of a title, a folder or "gelesen"
+rewrote the whole row with its file (megabytes, for a scanned volume tens of them), and every
+`EBook.objects.get()` loaded the file. `EBook.objects.create(data_ct=…, data_iv=…)` still
+works (the manager makes the EBookFile too); the API is unchanged.
+
+Migration `books/0008_ebook_file` copied 430 files in one statement and dropped the two
+columns: about 2 minutes on the server (rehearsed on a copy), the site down meanwhile. The
+pages the old columns used are free now and get reused; `VACUUM` (exclusive, minutes, at a
+quiet time) would give them back to the disk.
diff --git a/docs/library.md b/docs/library.md
new file mode 100644
index 0000000..257e978
--- /dev/null
+++ b/docs/library.md
@@ -0,0 +1,226 @@
+# Folders and the library (redesign)
+
+## Folders everywhere: tags shown as filter chips
+
+One idea of a folder for every area — Bücher, Zeitschriften, Hörbücher,
+Podcasts, Blogs (and the news' Ressorts, which already work this way):
+
+- A thing can be in **several folders** (they work like tags); none is fine.
+- Folders are **filters**, not rooms: a row of chips on top —
+ `Alle · n · … · Ohne Ordner` — tapping one filters in place; no
+ "inside a folder" view, no back button. On a wide window the row wraps.
+- **Assigning**: mark one or more things (long press / ☐ select mode, or ⋮ on
+ one), then "In Ordner …": a sheet with every folder of the area as checkboxes
+ and "Neuer Ordner". No typing a name that must match.
+- **Managing** (⋮ on the chip row → "Ordner verwalten"): rename, merge (rename
+ onto an existing name), delete (the things stay, only the folder goes),
+ reorder by dragging. The order is the chip order.
+- **Automatic groups** stay automatic and look different from folders (no
+ chip; a stack/group heading): audiobook series, magazine periodicals.
+
+### Where folders are kept
+
+| Area | Field | Notes |
+|------|-------|-------|
+| E-books, magazines | encrypted meta `folders: [names]` | `folder` (the first) kept for older clients; read `folders` or else `[folder]` |
+| Audiobooks, podcasts | `PodcastFeed.folders` (JSON list) | feed setting `folders`; `folder` (string) still accepted |
+| Blogs | `Source.folders` (exists) | |
+| News | `Source.category` (one Ressort) | already chips in Nachrichten |
+
+The folder order per area: e-books in the encrypted user settings blob (names
+are private), the others on the server (`/podcasts/folders/`, `/feeds/folders/`,
+`{order: [names]}`).
+
+### Podcasts: a category's episodes, by time
+
+The folder chips stand over „Folgen“ too: with one chosen it lists every episode of
+that category (`GET /podcasts/episodes/?folder=`, empty: the podcasts in none).
+„Folgen“, „Neu“ and one podcast's page group their episodes by time — Heute, Gestern,
+Diese Woche (the last 7 days), Diesen Monat (30 days), then the months — after the
+Kino feed. In „Folgen“ (sort „Nach Zeitraum, seltene zuerst“, the default) and „Neu“
+the podcasts that seldom come go first within a group: `interval_days` of each feed
+in `/podcasts/state/`, the mean gap between its newest 10 episodes (one episode: its
+age, at least 30 days, the same when all came within an hour; kept an hour). Heard is a filter chip, not an order.
+
+### Shared code: `static/js/folders.js` (diora-web, every platform)
+
+```
+DioraFolders.chips(el, {folders: [{name, count}], active, none: count|0, onPick(name|''|null), onManage()})
+DioraFolders.assign({title, folders: [names], selected: [names], onDone(names)}) // the sheet
+DioraFolders.manage({folders: [names], onRename(old, new), onDelete(name), onOrder(names)})
+```
+Each area keeps its own storage and calls these for the look and the dialogs.
+
+## The library (Bücher, Zeitschriften)
+
+- **Header**: title, search (title, author), sort (zuletzt gelesen · Titel ·
+ Autor · hinzugefügt · kürzeste / längste zuerst), ⊞/☰ grid or list, `+` (Buch hinzufügen, Aus Cloud).
+- **Length** (since 2026-10-25): the pages in the encrypted meta (`pages`) — a PDF's pages, an
+ EPUB's text by 1800 characters a page (`bookPagesOfFile`, 40-epub.js; the reader's own
+ estimate). Set on upload and when a book is opened; for older books the device works it out
+ once when a length sort is chosen ("Länge wird ermittelt … 3 von 80": file from the offline
+ copy or the server, decrypted on the device). Unknown lengths come last; the cards show
+ "· 320 S." while sorted by length.
+- **Filter chips**: Alle · Angefangen · Ungelesen · Gelesen — then the folders.
+- **Weiterlesen**: one card on top (cover, title, "Seite 132 von 186 · gestern",
+ ▶), not repeated in the list below.
+- **Grid** (default on phone and desktop): covers with a progress line, title.
+ **List** (default on e-ink / diora Reader): one row per book, small cover,
+ title, author, % / ✓.
+
+### Covers, end-to-end encrypted
+
+A client that has a book's file extracts its cover (EPUB: the OPF cover image;
+PDF: page 1), scales it to 240 px wide JPEG, **encrypts it with the user's key**
+(AES-GCM like the meta) and uploads it once: `POST /books//cover/
+{cover_ct, cover_iv}`. `GET /books/` lists `has_cover`; `GET /books//cover/`
+returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The
+server never sees a cover.
+
+### Editions
+
+Two or more books can be **editions of one work** (the PDF scan and the EPUB, two
+translations …): the same random key in each one's encrypted meta, `work: "w…"`
+(`''` or missing = none). The list shows a work once — the edition read last, else an EPUB,
+else the first added — with its formats on the cover ("EPUB · PDF", in the list view after
+the author). Each edition keeps its own file, position and highlights.
+
+- Joining: choose several books → "Als Ausgaben verbinden", or a book's ⋮ → "Als Ausgabe
+ verbinden …" (other books listed, similar titles first, with a search).
+- ⋮ → "Ausgaben (n) …" lists the editions: open one, "Lösen" sets one apart (the last one
+ left is set apart with it), "Weitere Ausgabe verbinden …".
+- For the apps' own book sheets: `window.bookEditions(id)` opens that sheet,
+ `window.bookEditionsOf(id)` returns the editions (the book alone if it has none).
+
+### Generated covers
+
+A book without a cover (or one set to `cover: 'generated'` in its meta) gets one
+drawn by `static/js/gencover.js`: an SVG, the same on every device, never stored.
+A **concept** (motif, palettes, typeface) is chosen by score:
+
+- words in the title (3), the shelf category (2) and the author (2, only at a
+ name's start). A stem of five letters or more counts anywhere (German
+ compounds), a shorter one at a word's start, one of three letters as a whole
+ word; `^` / `$` in the list force a word's start / end.
+- the folders, by name (`FOLDER_THEMES`): the first counts 2.5 — more than an
+ author or shelf word, less than a title word — further ones 1.
+
+A work of Marx or Engels (author, or a volume of the MEW series) is always a **blue volume**
+(blau: blue cloth, gold rules and capitals, the spine on the left with the volume number);
+no other book is.
+
+Concepts: konstruktiv (Marxist and political titles), geometrie (Philosophie: rings, turning squares,
+triangle/circle/square), psyche (Psychoanalyse: two ellipses, the Borromean
+knot, a spiral), karte (Geschichte), kosmos (Science-Fiction), ornament
+(Fantasy), raster (Politik, Soziologie), schatten (Krimi: a red line, a lit
+doorway), grusel (Horror), klassiker (patterned paper with a title plate),
+sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik),
+typo, wellen, botanik, berge, zellen, and since 2026-10-25 funk (talk and radio: waveform,
+mast, speech bubbles), musik (record, staff), kunst (colour fields, brushstrokes), essen (plate
+on a checked cloth, citrus), sport (track, pitch), geld (price chart, coins), reise (road,
+route with compass) — and bauhaus when nothing matches (shapes, quarter-circle tiles, a
+circle crossed by diagonals). Most concepts have two or three motifs. A hash of title and
+author picks motif, palette, layout and every detail.
+
+**Square** (`{square: true}`, since 2026-10-25): audiobooks and podcasts without a picture, or
+whose picture doesn't load (audiobooks.js; diora-android native-podcast.js `art()`, before the
+initial letter). 300 × 300, three title lines at most; the band of the motif shown is centred
+in what the text leaves free. Podcasts (`podcast: true`) nothing matches get funk, not
+bauhaus; a Marx/Engels audiobook gets konstruktiv (the blue volume is a binding).
+
+### Export as XML (since 2026-10-24)
+
+⇩ in the library's head (`static/js/app/55-library-tools.js`) saves `diora-bibliothek-.xml`:
+made on the device from the list it has decrypted (nothing new goes to the server), every book
+and magazine issue with its diora metadata — title, author, file name, ISBN, pages, shelf
+category, series and volume, work (editions), magazine mark, cover choice, folders —, the
+reading position (`
', 'vtt')
+
+ def test_choice(self):
+ from .transcripts import candidates
+ ts = [
+ {'url': 'https://x.org/a.srt', 'type': 'application/x-subrip', 'language': 'de'},
+ {'url': 'https://x.org/a.html', 'type': 'text/html', 'language': 'de'},
+ {'url': 'https://x.org/en.json', 'type': 'application/json', 'language': 'en'},
+ {'url': 'https://x.org/a.vtt', 'type': 'text/vtt', 'language': 'de-DE'},
+ {'url': 'https://x.org/fr.json', 'type': 'application/json', 'language': 'fr'},
+ {'url': 'https://x.org/b.vtt', 'type': '', 'language': ''},
+ ]
+ self.assertEqual([t['url'] for t in candidates(ts)], [
+ 'https://x.org/a.vtt', 'https://x.org/b.vtt', 'https://x.org/a.srt',
+ 'https://x.org/en.json', 'https://x.org/fr.json',
+ ])
+
+
+class TranscriptEndpointTests(TestCase):
+ VTT_T = {'url': 'https://example.org/a.vtt', 'type': 'text/vtt', 'language': 'de'}
+ JSON_T = {'url': 'https://example.org/a.json', 'type': 'application/json', 'language': 'de'}
+ HTML_T = {'url': 'https://example.org/a.html', 'type': 'text/html', 'language': 'de'}
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
+ self.ep = PodcastEpisode.objects.create(
+ feed=self.feed, guid='a', title='A', audio_url='https://example.org/a.mp3',
+ chapters=[{'start': 0.0, 'title': 'Inline'}], # no chapter fetch in these tests
+ transcripts=[self.VTT_T, self.JSON_T, self.HTML_T],
+ )
+ self.requests = []
+ self.responses = {
+ self.VTT_T['url']: lambda: _FakeResponse(VTT.encode()),
+ self.JSON_T['url']: lambda: _FakeResponse(json.dumps(TRANSCRIPT_JSON).encode()),
+ }
+
+ def fake_get(self, url, **kwargs):
+ self.requests.append(url)
+ return self.responses[url]()
+
+ def get(self, ep=None, what='transcript/'):
+ with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.get', self.fake_get):
+ return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/{what}')
+
+ def test_best_transcript_is_fetched_once(self):
+ r = self.get()
+ self.assertEqual(r.status_code, 200)
+ data = r.json()
+ self.assertEqual(data['source'], self.JSON_T)
+ self.assertEqual(data['segments'][0], {'start': 0.0, 'end': 1.2, 'speaker': 'Anna', 'text': 'Hallo zusammen.'})
+ self.assertEqual(self.get().json(), data)
+ self.assertEqual(self.requests, [self.JSON_T['url']])
+ self.assertTrue(self.get(what='').json()['has_transcript'])
+
+ def test_falls_back_to_the_next_one(self):
+ self.responses[self.JSON_T['url']] = lambda: _FakeResponse(status=404)
+ data = self.get().json()
+ self.assertEqual(data['source'], self.VTT_T)
+ self.assertEqual(data['segments'][1]['speaker'], 'Ben')
+ self.get()
+ self.assertEqual(self.requests, [self.JSON_T['url'], self.VTT_T['url']])
+
+ def test_temporary_failure_is_not_cached(self):
+ self.responses[self.JSON_T['url']] = lambda: _FakeResponse(status=503)
+ self.responses[self.VTT_T['url']] = lambda: _FakeResponse(status=503)
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (502, {'error': 'unavailable'}))
+ # The better one failing for now: the other one is shown, but not kept.
+ self.responses[self.VTT_T['url']] = lambda: _FakeResponse(VTT.encode())
+ self.assertEqual(self.get().json()['source'], self.VTT_T)
+ self.responses[self.JSON_T['url']] = lambda: _FakeResponse(json.dumps(TRANSCRIPT_JSON).encode())
+ self.assertEqual(self.get().json()['source'], self.JSON_T)
+ self.get()
+ self.assertEqual(self.requests.count(self.JSON_T['url']), 3)
+
+ def test_html_only_is_unsupported(self):
+ self.ep.transcripts = [self.HTML_T]
+ self.ep.save()
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'unsupported'}))
+ self.assertEqual(self.requests, [])
+ self.assertFalse(self.get(what='').json()['has_transcript'])
+
+ def test_html_served_under_another_type(self):
+ self.ep.transcripts = [self.VTT_T]
+ self.ep.save()
+ self.responses[self.VTT_T['url']] = lambda: _FakeResponse(b'Hallo')
+ self.assertTrue(self.get(what='').json()['has_transcript'])
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'unsupported'}))
+ self.assertEqual(self.get().status_code, 404)
+ self.assertEqual(self.requests, [self.VTT_T['url']])
+ self.assertFalse(self.get(what='').json()['has_transcript'])
+
+ def test_no_transcript(self):
+ self.ep.transcripts = []
+ self.ep.save()
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'no transcript'}))
+ self.assertFalse(self.get(what='').json()['has_transcript'])
+
+ def test_new_transcripts_in_the_feed_replace_the_cache(self):
+ self.ep.transcripts = [self.VTT_T]
+ self.ep.save()
+ self.assertEqual(self.get().json()['source'], self.VTT_T)
+ self.ep.transcripts = [self.VTT_T, self.JSON_T]
+ self.ep.save()
+ self.assertEqual(self.get().json()['source'], self.JSON_T)
+ self.assertEqual(self.requests, [self.VTT_T['url'], self.JSON_T['url']])
+
+ def test_other_users_episode_is_not_found(self):
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ feed = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/b', title='B')
+ ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3',
+ transcripts=[self.VTT_T])
+ r = self.get(ep)
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'not found'}))
+ self.assertEqual(self.requests, [])
+
+ def test_login_required(self):
+ self.client.logout()
+ self.assertEqual(self.get().status_code, 401)
+
+
+class AutoDownloadDefaultTests(TestCase):
+ """The account-wide default that new subscriptions start with."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+
+ def set_default(self, value):
+ return self.client.post('/podcasts/settings/', json.dumps({'auto_download_default': value}),
+ content_type='application/json')
+
+ def test_setting(self):
+ self.assertIs(self.client.get('/podcasts/settings/').json()['auto_download_default'], False)
+ self.assertIs(self.set_default(True).json()['auto_download_default'], True)
+ self.assertTrue(PodcastSettings.objects.get(user=self.user).auto_download_default)
+ self.assertIs(self.client.get('/podcasts/state/').json()['settings']['auto_download_default'], True)
+ for bad in (1, 'true', None):
+ with self.subTest(value=bad):
+ r = self.set_default(bad)
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('auto_download_default', r.json()['error'])
+ self.assertTrue(PodcastSettings.objects.get(user=self.user).auto_download_default)
+ self.assertIs(self.set_default(False).json()['auto_download_default'], False)
+
+ def test_new_subscriptions_start_with_it(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+ rss = _rss([('e1', 'Mon, 05 Jan 2026 10:00:00 GMT')])
+
+ def add(url):
+ with patch('podcasts.views._fetch_feed', lambda u, **kw: _ok(rss)):
+ self.client.post('/podcasts/feeds/add/', json.dumps({'rss_url': url}),
+ content_type='application/json')
+
+ def opml(url):
+ body = f''
+ self.client.post('/podcasts/feeds/import/',
+ {'file': SimpleUploadedFile('f.opml', body.encode(), 'text/x-opml')})
+
+ def gpodder(url):
+ auth = 'Basic ' + base64.b64encode(b'alice:pw12345678').decode()
+ r = self.client.post('/api/2/subscriptions/alice/phone.json', json.dumps({'add': [url]}),
+ content_type='application/json', HTTP_AUTHORIZATION=auth)
+ self.assertEqual(r.status_code, 200, r.content)
+
+ def auto_download(url):
+ return PodcastFeed.objects.get(user=self.user, rss_url=url).auto_download
+
+ add('https://example.org/a1')
+ opml('https://example.org/o1')
+ gpodder('https://example.org/g1')
+ self.set_default(True)
+ add('https://example.org/a2')
+ opml('https://example.org/o2')
+ gpodder('https://example.org/g2')
+ self.assertEqual(
+ [auto_download(f'https://example.org/{n}') for n in ('a1', 'o1', 'g1', 'a2', 'o2', 'g2')],
+ [False, False, False, True, True, True],
+ )
+ # Existing feeds keep theirs.
+ self.set_default(False)
+ opml('https://example.org/a2')
+ self.assertTrue(auto_download('https://example.org/a2'))
+
+
+class FeedCountsTests(TestCase):
+ """Per feed in feeds/ and state/: episodes, unplayed, new, last listened;
+ and what a podcast's own page needs (feeds//episodes/)."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
+ link='https://example.org/', description='Ein Podcast über A.')
+ self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
+ self.empty = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Leer')
+ eps = [PodcastEpisode.objects.create(feed=self.fa, guid=f'a{i}', title=f'A{i}', pub_date=_at(1, i + 1),
+ audio_url=f'https://example.org/a{i}.mp3') for i in range(5)]
+ self.b1 = PodcastEpisode.objects.create(feed=self.fb, guid='b1', title='B1', audio_url='https://example.org/b1.mp3')
+ _progress(self.user, eps[0], _at(2, 1), played=True)
+ _progress(self.user, eps[1], _at(2, 3), position_seconds=40)
+ _progress(self.user, eps[2], _at(2, 5), dismissed=True)
+ _progress(self.user, eps[3], _at(2, 9), favorite=True) # a star isn't listening
+ self.eps = eps
+ # Bob's progress on alice's episode (can't happen) and his own feed don't count.
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ _progress(bob, eps[4], _at(3, 1), played=True)
+ fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
+ xb = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
+ _progress(bob, xb, _at(3, 1), played=True)
+
+ def test_counts_in_feeds_and_state(self):
+ for url in ('/podcasts/feeds/', '/podcasts/state/'):
+ with self.subTest(url=url):
+ feeds = {f['id']: f for f in self.client.get(url).json()['feeds']}
+ a, b, c = feeds[self.fa.id], feeds[self.fb.id], feeds[self.empty.id]
+ self.assertEqual((a['episode_count'], a['unplayed_count'], a['new_count']), (5, 4, 3))
+ self.assertEqual(a['last_played_at'], '2026-02-03T09:00:00+00:00')
+ self.assertEqual(a['link'], 'https://example.org/')
+ self.assertEqual((b['episode_count'], b['unplayed_count'], b['new_count'], b['last_played_at']),
+ (1, 1, 1, None))
+ self.assertEqual((c['episode_count'], c['unplayed_count'], c['new_count'], c['last_played_at']),
+ (0, 0, 0, None))
+ # The shownotes-sized description stays out of the state.
+ self.assertNotIn('description', a)
+
+ def test_new_count_matches_the_inbox(self):
+ inbox = self.client.get('/podcasts/state/').json()['inbox']
+ feeds = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}
+ for feed in (self.fa, self.fb):
+ self.assertEqual(feeds[feed.id]['new_count'], sum(1 for e in inbox if e['feed__id'] == feed.id))
+
+ def test_two_queries_however_many_feeds(self):
+ from .views import _feeds_data
+ for i in range(10):
+ f = PodcastFeed.objects.create(user=self.user, rss_url=f'https://example.org/n{i}', title=f'N{i}')
+ e = PodcastEpisode.objects.create(feed=f, guid='e', title='E', audio_url='https://example.org/e.mp3')
+ _progress(self.user, e, _at(2, 2), played=True)
+ cache.clear()
+ with self.assertNumQueries(3): # feeds, progress counts, how often they come
+ feeds = _feeds_data(self.user)
+ self.assertEqual(len(feeds), 13)
+ with self.assertNumQueries(2): # that one is kept
+ _feeds_data(self.user)
+ self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
+
+ def test_feed_page_has_favorites_and_info(self):
+ data = self.client.get(f'/podcasts/feeds/{self.fa.id}/episodes/').json()
+ favs = {e['id']: e['favorite'] for e in data['episodes']}
+ self.assertEqual(favs[self.eps[3].id], True)
+ self.assertEqual(favs[self.eps[0].id], False)
+ self.assertEqual(favs[self.eps[4].id], False) # bob's row isn't alice's
+ self.assertEqual({k: data['feed'][k] for k in ('description', 'link', 'rss_url', 'last_refreshed_at')}, {
+ 'description': 'Ein Podcast über A.', 'link': 'https://example.org/', 'rss_url': 'https://example.org/a',
+ 'last_refreshed_at': None,
+ })
+
+
+class BulkFavoriteTests(TestCase):
+ """POST /podcasts/episodes/favorite/: star or unstar several at once."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
+ self.a, self.b, self.c = (
+ PodcastEpisode.objects.create(feed=feed, guid=g, title=g, audio_url=f'https://example.org/{g}.mp3')
+ for g in 'abc'
+ )
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
+ self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
+
+ def post(self, body):
+ return self.client.post('/podcasts/episodes/favorite/', json.dumps(body), content_type='application/json')
+
+ def test_star_and_unstar(self):
+ p = _progress(self.user, self.a, _at(2, 1), position_seconds=30)
+ r = self.post({'episode_ids': [self.a.id, self.b.id, self.bobs.id, 999999], 'favorite': True})
+ self.assertEqual(r.json(), {'ok': True, 'favorite': True, 'count': 2})
+ self.assertEqual(set(EpisodeProgress.objects.filter(user=self.user, favorite=True)
+ .values_list('episode_id', flat=True)), {self.a.id, self.b.id})
+ # The listening time of an existing row stays (history, statistics).
+ p.refresh_from_db()
+ self.assertEqual((p.position_seconds, p.updated_at), (30, _at(2, 1)))
+ self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
+ # Again: nothing doubles.
+ self.post({'episode_ids': [self.a.id, self.b.id], 'favorite': True})
+ self.assertEqual(EpisodeProgress.objects.filter(user=self.user).count(), 2)
+ r = self.post({'episode_ids': [self.a.id, self.b.id, self.c.id], 'favorite': False})
+ self.assertEqual(r.json(), {'ok': True, 'favorite': False, 'count': 3})
+ self.assertFalse(EpisodeProgress.objects.filter(favorite=True).exists())
+ # Unstarring doesn't make rows for episodes that had none.
+ self.assertFalse(EpisodeProgress.objects.filter(episode=self.c).exists())
+ # The list shows it.
+ self.post({'episode_ids': [self.c.id], 'favorite': True})
+ favs = self.client.get('/podcasts/episodes/', {'filter': 'favorites'}).json()['episodes']
+ self.assertEqual([e['id'] for e in favs], [self.c.id])
+
+ def test_bad_requests(self):
+ for body in ({}, {'episode_ids': [self.a.id]}, {'episode_ids': [], 'favorite': True},
+ {'episode_ids': self.a.id, 'favorite': True}, {'episode_ids': ['x'], 'favorite': True},
+ {'episode_ids': [self.a.id], 'favorite': 'yes'}, [1, 2]):
+ with self.subTest(body=body):
+ self.assertEqual(self.post(body).status_code, 400)
+ self.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405)
+ self.client.logout()
+ self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401)
+
+
+class PlaybackSettingsTests(TestCase):
+ """The account's playback settings: skip lengths, speed presets, what
+ skipping and deleting do, mobile data, times at the speed."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+
+ def post(self, body):
+ return self.client.post('/podcasts/settings/', json.dumps(body), content_type='application/json')
+
+ def settings(self):
+ return {k: getattr(PodcastSettings.for_user(self.user), k) for k in PLAYBACK_DEFAULTS}
+
+ def test_defaults(self):
+ r = self.client.get('/podcasts/settings/').json()
+ self.assertEqual({k: r[k] for k in PLAYBACK_DEFAULTS}, PLAYBACK_DEFAULTS)
+ self.assertEqual(r['skip_choices'], [5, 10, 15, 20, 30, 45, 60])
+ self.assertEqual(r['speed_range'], [0.5, 3.5])
+
+ def test_change_all(self):
+ body = {'skip_back': 10, 'skip_forward': 30, 'smart_played': 30, 'speed_presets': [3, 1, 2.5, 2, 2.0],
+ 'keep_skipped': False, 'delete_dequeue': False, 'mobile_downloads': False,
+ 'mobile_streaming': 'ask', 'times_by_speed': True}
+ r = self.post(body)
+ self.assertEqual(r.status_code, 200, r.content)
+ expected = {**body, 'speed_presets': [1.0, 2.0, 2.5, 3.0]}
+ self.assertEqual({k: r.json()[k] for k in PLAYBACK_DEFAULTS}, expected)
+ self.assertEqual(self.settings(), expected)
+ self.assertEqual({k: self.client.get('/podcasts/state/').json()['settings'][k] for k in PLAYBACK_DEFAULTS},
+ expected)
+ # Only what is sent changes; null unsets again.
+ r = self.post({'mobile_streaming': 'never', 'speed_presets': None, 'skip_back': None})
+ self.assertEqual({k: r.json()[k] for k in ('mobile_streaming', 'speed_presets', 'skip_back', 'skip_forward')},
+ {'mobile_streaming': 'never', 'speed_presets': None, 'skip_back': None, 'skip_forward': 30})
+
+ def test_presets_up_to_three_and_a_half(self):
+ self.assertEqual(self.post({'speed_presets': [0.5, 3.5, 1.234]}).json()['speed_presets'], [0.5, 1.23, 3.5])
+ self.assertEqual(self.post({'speed_presets': [1.0] * 5}).json()['speed_presets'], [1.0])
+
+ def test_invalid_values_change_nothing(self):
+ self.post({'skip_back': 15})
+ bad = [
+ ('skip_back', 7), ('skip_back', 0), ('skip_back', '10'), ('skip_back', 10.5), ('skip_back', True),
+ ('skip_forward', 90), ('smart_played', -1), ('smart_played', 3601), ('smart_played', 'x'),
+ ('speed_presets', []), ('speed_presets', [0.4]), ('speed_presets', [3.6]), ('speed_presets', ['1']),
+ ('speed_presets', 1.5), ('speed_presets', [0.5 + 0.25 * i for i in range(13)]),
+ ('keep_skipped', 1), ('delete_dequeue', None), ('mobile_downloads', 'false'), ('times_by_speed', 0),
+ ('mobile_streaming', 'sometimes'), ('mobile_streaming', None), ('enqueue_position', 'middle'),
+ ]
+ for name, value in bad:
+ with self.subTest(name=name, value=value):
+ r = self.post({'keep_skipped': False, name: value} if name != 'keep_skipped' else {name: value})
+ self.assertEqual(r.status_code, 400)
+ self.assertIn(name, r.json()['error'])
+ self.assertEqual(r.json()['field'], name)
+ self.assertEqual(self.settings(), {**PLAYBACK_DEFAULTS, 'skip_back': 15})
+ self.assertEqual(self.post([1]).status_code, 400)
+ self.assertEqual(self.post({'initial': [1]}).status_code, 400)
+
+ def test_initial_sets_only_what_is_unset(self):
+ """An app hands over its own values once: they only fill what the account doesn't have yet."""
+ self.post({'skip_forward': 45})
+ r = self.post({'initial': {'skip_back': 10, 'skip_forward': 30, 'smart_played': 60,
+ 'keep_skipped': False}}) # keep_skipped isn't one that starts unset
+ self.assertEqual(r.status_code, 200, r.content)
+ self.assertEqual({k: r.json()[k] for k in ('skip_back', 'skip_forward', 'smart_played', 'keep_skipped')},
+ {'skip_back': 10, 'skip_forward': 45, 'smart_played': 60, 'keep_skipped': True})
+ # A second device comes too late: nothing changes.
+ r = self.post({'initial': {'skip_back': 5, 'smart_played': 0, 'speed_presets': [1, 2]}})
+ self.assertEqual({k: r.json()[k] for k in ('skip_back', 'smart_played', 'speed_presets')},
+ {'skip_back': 10, 'smart_played': 60, 'speed_presets': [1.0, 2.0]})
+ # Invalid initial values are refused like any other.
+ r = self.post({'initial': {'skip_back': 7}})
+ self.assertEqual((r.status_code, r.json()['field']), (400, 'skip_back'))
+ # Initial and a change at once: the change wins.
+ PodcastSettings.objects.filter(user=self.user).update(smart_played=None)
+ r = self.post({'smart_played': 120, 'initial': {'smart_played': 30}})
+ self.assertEqual(r.json()['smart_played'], 120)
+
+ def test_login(self):
+ self.client.logout()
+ self.assertEqual(self.post({'skip_back': 10}).status_code, 401)
+ self.assertEqual(self.client.get('/podcasts/settings/').status_code, 401)
+
+
+class DiscardTests(TestCase):
+ """POST /podcasts/episodes/discard/ ("Folge löschen"): out of the queue
+ and "Neu", the downloads go on every device (state/ "discarded"), but
+ not played: the place and the star stay."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
+ base = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ self.eps = [
+ PodcastEpisode.objects.create(feed=feed, guid=f'e{i}', title=f'E{i}', duration_seconds=3600,
+ audio_url=f'https://example.org/e{i}.mp3', pub_date=base + timedelta(days=i))
+ for i in range(5)
+ ]
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
+ self.bob = bob
+ self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
+ PodcastQueue.objects.create(user=bob, episode=self.bobs, position=0)
+
+ def post(self, body):
+ return self.client.post('/podcasts/episodes/discard/', json.dumps(body), content_type='application/json')
+
+ def queue(self):
+ return list(PodcastQueue.objects.filter(user=self.user).order_by('position', 'added_at', 'id')
+ .values_list('episode_id', flat=True))
+
+ def state(self):
+ return self.client.get('/podcasts/state/').json()
+
+ def test_out_of_queue_and_inbox_not_played(self):
+ e0, e1, e2, e3, e4 = self.eps
+ for i, e in enumerate((e0, e1, e2)):
+ PodcastQueue.objects.create(user=self.user, episode=e, position=i)
+ p1 = _progress(self.user, e1, _at(2, 1), position_seconds=754, favorite=True)
+ self.assertIn(e3.id, [x['id'] for x in self.state()['inbox']])
+ r = self.post({'episode_ids': [e1.id, e3.id]})
+ self.assertEqual(r.status_code, 200)
+ d = r.json()
+ self.assertEqual((d['ok'], d['count']), (True, 2))
+ # The rest of the queue keeps its order.
+ self.assertEqual(self.queue(), [e0.id, e2.id])
+ # The place, the star and the listening time stay; not played.
+ p1.refresh_from_db()
+ self.assertEqual((p1.position_seconds, p1.favorite, p1.played, p1.dismissed), (754, True, False, True))
+ self.assertEqual(p1.updated_at, _at(2, 1))
+ self.assertIsNotNone(p1.discarded_at)
+ # An episode without a row gets one: out of "Neu", not played, no place.
+ p3 = EpisodeProgress.objects.get(user=self.user, episode=e3)
+ self.assertEqual((p3.played, p3.dismissed, p3.position_seconds), (False, True, 0))
+ s = self.state()
+ inbox = [x['id'] for x in s['inbox']]
+ self.assertNotIn(e3.id, inbox)
+ self.assertIn(e4.id, inbox)
+ self.assertEqual([q['episode__id'] for q in s['queue']], [e0.id, e2.id])
+ # The state lists both for the other devices, with the time of the discard.
+ self.assertEqual({x['id'] for x in s['discarded']}, {e1.id, e3.id})
+ at = next(x['at'] for x in s['discarded'] if x['id'] == e1.id)
+ self.assertRegex(at, r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$')
+ self.assertEqual(at, d['at'])
+ # Still unplayed in the episode lists, with its place.
+ unplayed = self.client.get('/podcasts/episodes/', {'filter': 'unplayed'}).json()['episodes']
+ row = next(x for x in unplayed if x['id'] == e1.id)
+ self.assertEqual((row['position_seconds'], row['played'], row['in_queue']), (754, False, False))
+
+ def test_queued_again_works_and_clears_it(self):
+ e0 = self.eps[0]
+ PodcastQueue.objects.create(user=self.user, episode=e0, position=0)
+ self.post({'episode_ids': [e0.id]})
+ self.assertEqual(self.queue(), [])
+ r = self.client.post('/podcasts/queue/add/', json.dumps({'episode_ids': [e0.id]}), content_type='application/json')
+ self.assertEqual(r.json()['added'], [e0.id])
+ self.assertEqual(self.queue(), [e0.id])
+ self.assertIsNone(EpisodeProgress.objects.get(user=self.user, episode=e0).discarded_at)
+ self.assertEqual(self.state()['discarded'], [])
+ # Playing it saves its place as ever.
+ self.client.post('/podcasts/progress/save/', json.dumps({'episode_id': e0.id, 'position_seconds': 99}),
+ content_type='application/json')
+ self.assertEqual(EpisodeProgress.objects.get(user=self.user, episode=e0).position_seconds, 99)
+
+ def test_kept_queue_order_stays_sorted(self):
+ PodcastSettings.objects.update_or_create(user=self.user, defaults={'keep_sorted': 'old'})
+ for e in self.eps:
+ self.client.post('/podcasts/queue/add/', json.dumps({'episode_ids': [e.id]}), content_type='application/json')
+ self.assertEqual(self.queue(), [e.id for e in self.eps])
+ self.post({'episode_ids': [self.eps[2].id]})
+ self.assertEqual(self.queue(), [self.eps[i].id for i in (0, 1, 3, 4)])
+
+ def test_old_discards_leave_the_state(self):
+ e0, e1 = self.eps[:2]
+ self.post({'episode_ids': [e0.id, e1.id]})
+ EpisodeProgress.objects.filter(user=self.user, episode=e0).update(
+ discarded_at=datetime.now(timezone.utc) - timedelta(days=31))
+ self.assertEqual([x['id'] for x in self.state()['discarded']], [e1.id])
+
+ def test_other_users_episodes_are_left_alone(self):
+ r = self.post({'episode_ids': [self.bobs.id, 999999]})
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.json()['count'], 0)
+ self.assertTrue(PodcastQueue.objects.filter(user=self.bob, episode=self.bobs).exists())
+ self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
+ self.assertEqual(self.state()['discarded'], [])
+
+ def test_bad_requests(self):
+ for body in ({}, {'episode_ids': []}, {'episode_ids': self.eps[0].id}, {'episode_ids': ['x']},
+ {'episode_ids': [True]}, [1, 2]):
+ with self.subTest(body=body):
+ self.assertEqual(self.post(body).status_code, 400)
+ self.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405)
+ self.client.logout()
+ self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401)
+
+
+# ---------------------------------------------------------------------------
+# Skip silence in the browser: pauses found on the server (silence.py)
+# ---------------------------------------------------------------------------
+
+class SilenceTests(TestCase):
+ """The rules are diora-android's PauseCutter: what Android cuts, the browser runs through."""
+
+ @staticmethod
+ def speech(seconds, db=-22.0):
+ return [db + (0.5 if i % 2 else 0.0) for i in range(int(seconds * 100))]
+
+ @staticmethod
+ def quiet(seconds, db=-75.0):
+ return [db] * int(seconds * 100)
+
+ def test_a_pause_keeps_a_fifth_half_before_half_after(self):
+ from .silence import cuts
+ # 2 s of speech (the noise floor needs some history), a 0.5 s pause, speech.
+ levels = self.speech(12) + self.quiet(0.5) + self.speech(2)
+ self.assertEqual(cuts(levels), [[12.05, 12.45]]) # keeps 100 ms: 50 + 50
+
+ def test_short_gaps_between_words_stay(self):
+ from .silence import cuts
+ self.assertEqual(cuts(self.speech(12) + self.quiet(0.08) + self.speech(2)), [])
+
+ def test_a_long_pause_keeps_at_most_a_quarter_second(self):
+ from .silence import cuts
+ self.assertEqual(cuts(self.speech(12) + self.quiet(3) + self.speech(2)), [[12.125, 14.875]])
+
+ def test_a_short_pause_keeps_at_least_60_ms(self):
+ from .silence import cuts
+ self.assertEqual(cuts(self.speech(12) + self.quiet(0.1) + self.speech(2)), [[12.03, 12.07]])
+
+ def test_a_quiet_second_voice_is_not_a_pause(self):
+ from .silence import cuts
+ # A talk with real pauses now and then (that's where the noise floor shows),
+ # then a voice well below the speaker but far above the floor: someone
+ # asking from the back. Only the real pauses are cut.
+ talk = (self.speech(1.8) + self.quiet(0.2)) * 6
+ levels = talk + self.quiet(2, db=-48.0) + self.speech(2)
+ found = cuts(levels)
+ self.assertEqual(len(found), 6)
+ self.assertTrue(all(end <= 12.0 for _, end in found))
+
+ def test_a_pause_running_to_the_end_stays(self):
+ from .silence import cuts
+ self.assertEqual(cuts(self.speech(12) + self.quiet(2)), [])
+
+ def setUp(self):
+ self.user = User.objects.create_user(username='alice', password='pw12345678')
+ self.client.force_login(self.user)
+ feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='F')
+ self.ep = PodcastEpisode.objects.create(feed=feed, guid='e', title='E', audio_url='https://example.org/e.mp3')
+
+ def test_first_request_starts_the_search_then_answers_with_the_pauses(self):
+ with patch('podcasts.silence.ensure') as ensure:
+ r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
+ self.assertEqual(r.json(), {'ready': False})
+ ensure.assert_called_once_with(self.ep.id)
+ with patch('podcasts.silence.analyze', return_value=[[1.5, 2.4]]):
+ from .silence import _work
+ _work(self.ep.id)
+ r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
+ self.assertEqual(r.json(), {'ready': True, 'v': 2, 'spans': [[1.5, 2.4]]})
+
+ def test_a_result_of_the_old_rules_is_found_anew(self):
+ PodcastEpisode.objects.filter(pk=self.ep.id).update(silences={'spans': [[1.0, 2.0]]})
+ with patch('podcasts.silence.ensure') as ensure:
+ r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
+ self.assertEqual(r.json(), {'ready': False})
+ ensure.assert_called_once_with(self.ep.id)
+
+ def test_a_failure_is_remembered_not_retried(self):
+ from .silence import _work
+ with patch('podcasts.silence.analyze', side_effect=RuntimeError('HTTP error 404 Not Found')):
+ _work(self.ep.id)
+ with patch('podcasts.silence.ensure') as ensure:
+ r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/')
+ ensure.assert_not_called()
+ self.assertEqual(r.json(), {'ready': True, 'v': 2, 'spans': [], 'error': 'HTTP error 404 Not Found'})
+
+ def test_only_own_episodes(self):
+ bob = User.objects.create_user(username='bob', password='pw12345678')
+ self.client.force_login(bob)
+ self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 404)
+ self.client.logout()
+ self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 401)
diff --git a/podcasts/transcripts.py b/podcasts/transcripts.py
new file mode 100644
index 0000000..d7dc368
--- /dev/null
+++ b/podcasts/transcripts.py
@@ -0,0 +1,282 @@
+"""Podcast transcripts (): pick the best one an episode
+offers, fetch it with the same guards as the chapters, and turn it into
+readable segments:
+
+ {"segments": [{"start": 1.5, "end": 9.25, "speaker": "Anna" | None, "text": "…"}],
+ "source": {"url": "…", "type": "text/vtt", "language": "de"}}
+
+Formats: Podcasting 2.0 JSON, WebVTT and SubRip (SRT). HTML transcripts have
+no timing and aren't supported. Cues are often a few words each; consecutive
+ones of the same speaker are merged up to a sentence end or about 30 s.
+"""
+
+import html
+import json
+import re
+
+from . import chapters as fetching
+
+MAX_BYTES = 2 * 1024 * 1024
+MAX_SEGMENTS = 20_000
+MAX_TEXT = 5_000 # characters of one segment
+MAX_SPEAKER = 200
+
+# Merging
+MERGE_MAX_SECONDS = 30 # a segment never spans more than this
+MERGE_MIN_SECONDS = 5 # ... and ends at a sentence end once it's this long
+MERGE_MAX_GAP = 5 # silence that always starts a new segment
+
+JSON, VTT, SRT, HTML = 'json', 'vtt', 'srt', 'html'
+_KIND_RANK = {JSON: 0, VTT: 1, SRT: 2}
+_ACCEPT = {JSON: 'application/json', VTT: 'text/vtt', SRT: 'application/x-subrip'}
+
+
+class Unsupported(Exception):
+ """The transcript is in a format without timing (HTML) or an unknown one."""
+
+
+def kind(transcript):
+ """json, vtt, srt, html or '' (unknown) from the type, else the URL."""
+ t = (transcript.get('type') or '').lower().split(';')[0].strip()
+ if 'json' in t:
+ return JSON
+ if 'vtt' in t:
+ return VTT
+ if 'srt' in t or 'subrip' in t:
+ return SRT
+ if 'html' in t:
+ return HTML
+ path = (transcript.get('url') or '').lower().split('?')[0].split('#')[0]
+ for ext, k in (('.json', JSON), ('.vtt', VTT), ('.srt', SRT), ('.html', HTML), ('.htm', HTML)):
+ if path.endswith(ext):
+ return k
+ return ''
+
+
+def _language_rank(language):
+ lang = (language or '').lower().split('-')[0].split('_')[0]
+ if lang in ('de', ''): # no language given: most likely the episode's own
+ return 0
+ if lang == 'en':
+ return 1
+ return 2
+
+
+def candidates(transcripts):
+ """The episode's transcripts in a format we read, best first: German (or
+ no language given) before English before others, then JSON before VTT
+ before SRT."""
+ usable = [(i, t) for i, t in enumerate(transcripts or []) if kind(t) in _KIND_RANK]
+ usable.sort(key=lambda it: (_language_rank(it[1].get('language')), _KIND_RANK[kind(it[1])], it[0]))
+ return [t for _, t in usable]
+
+
+# ---------------------------------------------------------------------------
+# Parsers: each returns fragments [(start, end, speaker, text)]
+# ---------------------------------------------------------------------------
+
+def _decode(body):
+ if body.startswith((b'\xff\xfe', b'\xfe\xff')):
+ return body.decode('utf-16', errors='replace')
+ try:
+ return body.decode('utf-8-sig')
+ except UnicodeDecodeError:
+ return body.decode('cp1252', errors='replace')
+
+
+def _clean(text):
+ text = re.sub(r'<[^>]*>', ' ', text)
+ return ' '.join(html.unescape(text).split())
+
+
+def _seconds(value):
+ try:
+ value = float(value)
+ except (TypeError, ValueError):
+ return None
+ if value != value or value < 0 or value == float('inf'):
+ return None
+ return value
+
+
+def parse_json(text):
+ """Podcasting 2.0 JSON: {"segments": [{"startTime", "endTime", "speaker", "body"}]}."""
+ try:
+ data = json.loads(text)
+ except ValueError:
+ raise fetching.FetchInvalid('not JSON')
+ segments = data.get('segments') if isinstance(data, dict) else None
+ if not isinstance(segments, list):
+ raise fetching.FetchInvalid('no segments list')
+ out = []
+ for s in segments:
+ if not isinstance(s, dict):
+ continue
+ start = _seconds(s.get('startTime'))
+ if start is None:
+ continue
+ end = _seconds(s.get('endTime'))
+ speaker = s.get('speaker')
+ speaker = ' '.join(speaker.split()) if isinstance(speaker, str) else None
+ body = s.get('body')
+ out.append((start, end, speaker or None, _clean(body) if isinstance(body, str) else ''))
+ return out
+
+
+# 00:01:02.500 / 01:02.500 (VTT), 00:01:02,500 (SRT)
+_TIME = r'(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?'
+_TIMING = re.compile(r'^\s*' + _TIME + r'\s*-->\s*' + _TIME + r'(?:\s|$)')
+_VOICE = re.compile(r']*)?(?:\s+([^>]*))?>', re.IGNORECASE)
+
+
+def _time(hours, minutes, seconds, fraction):
+ return int(hours or 0) * 3600 + int(minutes) * 60 + int(seconds) + (int(fraction.ljust(3, '0')) / 1000 if fraction else 0)
+
+
+def _cue_fragments(start, end, lines):
+ """Split a cue's text at its voice tags (…) into fragments."""
+ text = '\n'.join(lines)
+ out = []
+ speaker, pos = None, 0
+ for m in _VOICE.finditer(text):
+ out.append((start, end, speaker, _clean(text[pos:m.start()])))
+ speaker = ' '.join((m.group(1) or '').split())[:MAX_SPEAKER] or None
+ pos = m.end()
+ out.append((start, end, speaker, _clean(text[pos:])))
+ return [f for f in out if f[3]]
+
+
+def parse_cues(text):
+ """WebVTT or SRT. Lines before a cue's timing line (VTT cue ids, SRT
+ numbers) are dropped, as is everything outside cues (the WEBVTT header,
+ NOTE, STYLE and REGION blocks)."""
+ cues, current = [], None
+ for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n'):
+ m = _TIMING.match(line)
+ if m:
+ if current is not None and current[2] and current[2][-1].strip().isdigit():
+ current[2].pop() # an SRT number with no blank line before it
+ g = m.groups()
+ current = (_time(*g[:4]), _time(*g[4:]), [])
+ cues.append(current)
+ elif not line.strip():
+ current = None
+ elif current is not None:
+ current[2].append(line)
+ out = []
+ for start, end, lines in cues:
+ out.extend(_cue_fragments(start, end, lines))
+ return out
+
+
+def parse(body, declared_kind):
+ """Fragments from a downloaded transcript; the content decides the format
+ when it's obvious, the declared type otherwise."""
+ text = _decode(body)
+ head = text.lstrip()[:200].lower()
+ if head.startswith(('= MERGE_MIN_SECONDS)
+ ):
+ last['text'] += ' ' + text
+ last['end'] = max(last['end'], end)
+ continue
+ if len(segments) >= MAX_SEGMENTS:
+ break
+ segments.append({'start': start, 'end': end, 'speaker': speaker, 'text': text[:MAX_TEXT]})
+ for s in segments:
+ s['start'], s['end'] = round(s['start'], 3), round(s['end'], 3)
+ return segments
+
+
+# ---------------------------------------------------------------------------
+# Fetching
+# ---------------------------------------------------------------------------
+
+def fetch(transcript):
+ """The parsed transcript {"segments", "source"} from one of the episode's
+ entries. Raises Unsupported, FetchInvalid or
+ FetchUnavailable."""
+ k = kind(transcript)
+ if k not in _KIND_RANK:
+ raise Unsupported(transcript.get('type') or 'unknown type')
+ body = fetching.fetch_bytes(
+ transcript['url'], MAX_BYTES, accept=f'{_ACCEPT[k]}, */*;q=0.1', agent='diora podcast transcripts',
+ )
+ segments = merge(parse(body, k))
+ if not segments:
+ raise fetching.FetchInvalid('no segments')
+ return {
+ 'segments': segments,
+ 'source': {
+ 'url': transcript['url'],
+ 'type': transcript.get('type') or '',
+ 'language': transcript.get('language') or '',
+ },
+ }
+
+
+def load(transcripts):
+ """(parsed transcript, final) from the best candidate that works.
+
+ final is False when a better candidate failed for now only, so the
+ result shouldn't be kept for good. Raises FetchUnavailable when nothing
+ worked and something may work later, Unsupported when no candidate is in
+ a usable format, FetchInvalid otherwise.
+ """
+ retry_later = False
+ unsupported_only = True
+ for t in candidates(transcripts):
+ try:
+ return fetch(t), not retry_later
+ except fetching.FetchUnavailable:
+ retry_later = True
+ unsupported_only = False
+ except Unsupported:
+ pass
+ except fetching.FetchInvalid:
+ unsupported_only = False
+ if retry_later:
+ raise fetching.FetchUnavailable('try again later')
+ if unsupported_only:
+ raise Unsupported('no usable transcript')
+ raise fetching.FetchInvalid('no usable transcript')
diff --git a/podcasts/urls.py b/podcasts/urls.py
index 4d7db04..aaaaa25 100644
--- a/podcasts/urls.py
+++ b/podcasts/urls.py
@@ -1,21 +1,66 @@
from django.urls import path
-from . import views
+from . import audiobooks, discover, folders, radio, 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/export.opml', views.export_opml, name='podcast_export_opml'),
path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'),
+ path('feeds/refresh-all/', views.refresh_all_feeds, name='podcast_refresh_all_feeds'),
+ path('state/', views.podcast_state, name='podcast_state'),
path('feeds//remove/', views.remove_feed, name='podcast_remove_feed'),
path('feeds//set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
+ path('feeds//settings/', views.feed_settings, name='podcast_feed_settings'),
+ path('feeds//move/', views.move_feed, name='podcast_move_feed'),
+ path('audiobooks/', audiobooks.audiobook_list, name='podcast_audiobooks'),
+ path('audiobooks//', audiobooks.audiobook_detail, name='podcast_audiobook'),
+ path('folders/', folders.folders, name='podcast_folders'),
+ path('folders/rename/', folders.rename_folder, name='podcast_folder_rename'),
+ path('folders/delete/', folders.delete_folder, name='podcast_folder_delete'),
+ path('bookmarks/', audiobooks.bookmark_add, name='podcast_bookmark_add'),
+ path('bookmarks//', audiobooks.bookmark_update, name='podcast_bookmark_update'),
+ path('bookmarks//delete/', audiobooks.bookmark_delete, name='podcast_bookmark_delete'),
path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'),
+ path('episodes/', views.episode_list, name='podcast_episode_list'),
+ path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
+ path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
+ path('episodes//', views.episode_detail, name='podcast_episode_detail'),
+ path('episodes//transcript/', views.episode_transcript, name='podcast_episode_transcript'),
+ path('episodes//silences/', views.episode_silences, name='podcast_episode_silences'),
+ path('episodes//sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
+ path('episodes//favorite/', views.set_favorite, name='podcast_set_favorite'),
+ path('stats/', views.podcast_stats, name='podcast_stats'),
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('later/add/', views.later_add, name='podcast_later_add'),
+ path('later/remove/', views.later_remove, name='podcast_later_remove'),
+ path('lists/', views.lists_switch, name='podcast_lists_switch'),
+ path('stream/seen/', views.stream_seen, name='podcast_stream_seen'),
+ path('radio/', radio.radio, name='podcast_radio'),
+ path('radio/heard/', radio.radio_heard, name='podcast_radio_heard'),
+ # Stöbern (docs/podcasts.md, podcasts/discover.py)
+ path('discover/search/', discover.search_view, name='podcast_discover_search'),
+ path('discover/similar//', discover.similar_view, name='podcast_discover_similar'),
+ path('discover/people/', discover.people_view, name='podcast_discover_people'),
+ path('discover/person/', discover.person_view, name='podcast_discover_person'),
+ path('discover/elsewhere/', discover.elsewhere_view, name='podcast_discover_elsewhere'),
+ path('discover/topics/', discover.topics_view, name='podcast_discover_topics'),
+ path('discover/forgotten/', discover.forgotten_view, name='podcast_discover_forgotten'),
+ path('discover/recommend/', discover.recommend_view, name='podcast_discover_recommend'),
+ path('discover/take/', discover.take_view, name='podcast_discover_take'),
+ path('settings/', views.podcast_settings, name='podcast_settings'),
path('progress/save/', views.save_progress, name='podcast_save_progress'),
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
path('progress/dismiss/', views.dismiss_episodes, name='podcast_dismiss_episodes'),
path('inbox/', views.inbox, name='podcast_inbox'),
+ path('import-downloads/', views.import_downloads, name='podcast_import_downloads'),
+ path('import-downloads/clear/', views.clear_import_downloads, name='podcast_clear_import_downloads'),
+ path('player/', views.player_state, name='podcast_player_state'),
+ path('player/report/', views.player_report, name='podcast_player_report'),
+ path('player/command/', views.player_command, name='podcast_player_command'),
+ path('player/stream/', views.player_stream, name='podcast_player_stream'),
]
diff --git a/podcasts/views.py b/podcasts/views.py
index 299f0e8..b856077 100644
--- a/podcasts/views.py
+++ b/podcasts/views.py
@@ -1,17 +1,287 @@
+import hashlib
import json
+import logging
+import math
+import re
+import time
import urllib.parse
+import uuid
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout, as_completed
+from dataclasses import dataclass
+from datetime import timedelta, timezone as dt_timezone
import xml.etree.ElementTree as ET
import feedparser
import requests
from django.conf import settings
-from django.db.models import Count, F, Max, Q
-from django.http import JsonResponse
+from django.core.cache import cache
+from django.db import IntegrityError, transaction
+from django.db.models import (
+ Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When, Window,
+)
+from django.db.models.functions import Coalesce, Lower, RowNumber
+from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
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
+from . import chapters as chapters_mod
+from . import silence as silence_mod
+from . import sponsor as sponsor_mod
+from . import feedext
+from . import transcripts as transcripts_mod
+from . import youtube as youtube_mod
+from . import folders as folders_mod
+from .models import (
+ EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
+ PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
+)
+from .shownotes import extract_timestamps, sanitize_html
+
+feedext.install()
+
+log = logging.getLogger(__name__)
+
+FEED_TIMEOUT = 20 # seconds per feed download
+NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
+FEED_WORKERS = 16 # feeds downloaded at the same time
+REFRESH_WAIT = 40 # seconds a refresh-all (⟳) waits; feeds not done by then wait for the hourly job
+MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
+MAX_TRANSCRIPTS = 10
+MAX_DURATION = 7 * 24 * 3600 # seconds: a longer one reported is nonsense
+
+
+# ---------------------------------------------------------------------------
+# Queue positions
+# ---------------------------------------------------------------------------
+
+QUEUE_POSITIONS = {key for key, _ in PodcastSettings.POSITIONS}
+KEEP_SORTED = {key for key, _ in PodcastSettings.SORT_ORDERS}
+
+
+def _queue_order(user):
+ """Episode ids in the user's queue, first to last."""
+ return list(
+ PodcastQueue.objects.filter(user=user)
+ .order_by('position', 'added_at', 'id')
+ .values_list('episode_id', flat=True)
+ )
+
+
+def _write_queue_order(user, order):
+ """Renumber the queue 0..n-1 in the given order."""
+ rows = {q.episode_id: q for q in PodcastQueue.objects.filter(user=user)}
+ changed = []
+ for pos, ep_id in enumerate(order):
+ row = rows.get(ep_id)
+ if row is not None and row.position != pos:
+ row.position = pos
+ changed.append(row)
+ if changed:
+ PodcastQueue.objects.bulk_update(changed, ['position'])
+
+
+def _current_episode_id(user, queued, hint=None):
+ """The episode being listened to: the client's word for it, else the
+ queued episode most recently listened to and not finished."""
+ if hint in queued:
+ return hint
+ p = (
+ EpisodeProgress.objects
+ .filter(user=user, episode_id__in=queued, played=False, position_seconds__gt=0)
+ .order_by('-updated_at')
+ .values_list('episode_id', flat=True)
+ .first()
+ )
+ return p
+
+
+def _not_books(episode_ids):
+ """The ids that aren't an audiobook's chapters: those are played in the library, in their
+ book's order, and never sit in the Wiedergabeliste or the Merkliste (docs/audiobooks.md)."""
+ books = set(PodcastEpisode.objects.filter(id__in=episode_ids, feed__kind=PodcastFeed.AUDIOBOOK)
+ .values_list('id', flat=True))
+ return [i for i in episode_ids if i not in books]
+
+
+def _enqueue(user, episode_ids, where, current_hint=None):
+ """Put episodes that aren't queued yet into the queue at `where`
+ (back, front or after_current), keeping their given order.
+ Returns the ids that were added."""
+ episode_ids = _not_books(episode_ids)
+ queued = _queue_order(user)
+ present = set(queued)
+ new = []
+ for ep_id in episode_ids:
+ if ep_id not in present and ep_id not in new:
+ new.append(ep_id)
+ if not new:
+ return []
+ if where == PodcastSettings.FRONT:
+ at = 0
+ elif where == PodcastSettings.AFTER_CURRENT:
+ current = _current_episode_id(user, queued, current_hint)
+ at = queued.index(current) + 1 if current in queued else 0
+ else:
+ at = len(queued)
+ PodcastQueue.objects.bulk_create(
+ [PodcastQueue(user=user, episode_id=ep_id, position=len(queued) + i) for i, ep_id in enumerate(new)],
+ ignore_conflicts=True,
+ )
+ _write_queue_order(user, queued[:at] + new + queued[at:])
+ _leave_inbox(user, new)
+ # Wanted again after "Folge löschen": devices keep (or fetch) their download.
+ EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None)
+ _keep_sorted(user)
+ return new
+
+
+def _dequeue(user, episode_ids):
+ """Take episodes out of the user's queue. The others keep their order
+ (a queue kept sorted stays sorted: taking some out doesn't change that)."""
+ PodcastQueue.objects.filter(user=user, episode_id__in=episode_ids).delete()
+
+
+def _sort_key(rule):
+ """Sort key for one queue row ({pub_date, duration, position, feed_title})
+ by a PodcastSettings.SORT_ORDERS rule. Ties keep their current order
+ (the sort is stable)."""
+ def ts(row):
+ return row['pub_date'].timestamp() if row['pub_date'] else None
+
+ def remaining(row):
+ return max((row['duration'] or 0) - (row['position'] or 0), 0)
+
+ if rule == 'new': # no date: last
+ return lambda r: (ts(r) is None, -(ts(r) or 0))
+ if rule == 'old': # no date: last as well
+ return lambda r: (ts(r) is None, ts(r) or 0)
+ if rule == 'short':
+ return remaining
+ if rule == 'long':
+ return lambda r: -remaining(r)
+ if rule == 'feed': # by podcast title, then newest first
+ return lambda r: ((r['feed_title'] or '').casefold(), ts(r) is None, -(ts(r) or 0))
+ return None
+
+
+def _keep_sorted(user, rule=None):
+ """Sort the user's whole queue by their keep_sorted rule (or `rule`), if
+ there is one. Returns True if it is kept sorted."""
+ if rule is None:
+ rule = PodcastSettings.objects.filter(user=user).values_list('keep_sorted', flat=True).first() or ''
+ key = _sort_key(rule)
+ if key is None:
+ return False
+ rows = list(
+ PodcastQueue.objects.filter(user=user)
+ .order_by('position', 'added_at', 'id')
+ .values('episode_id', pub_date=F('episode__pub_date'), duration=F('episode__duration_seconds'),
+ feed_title=F('episode__feed__title'))
+ )
+ positions = dict(
+ EpisodeProgress.objects.filter(user=user, episode_id__in=[r['episode_id'] for r in rows])
+ .values_list('episode_id', 'position_seconds')
+ )
+ for r in rows:
+ r['position'] = positions.get(r['episode_id'], 0)
+ rows.sort(key=key)
+ _write_queue_order(user, [r['episode_id'] for r in rows])
+ return True
+
+
+def _leave_inbox(user, episode_ids):
+ """Queued or started episodes are no longer new (AntennaPod's rule): out
+ of the inbox, without counting as played."""
+ EpisodeProgress.objects.filter(user=user, episode_id__in=episode_ids, dismissed=False).update(dismissed=True)
+ have = set(EpisodeProgress.objects.filter(user=user, episode_id__in=episode_ids).values_list('episode_id', flat=True))
+ EpisodeProgress.objects.bulk_create(
+ [EpisodeProgress(user=user, episode_id=ep_id, dismissed=True) for ep_id in episode_ids if ep_id not in have],
+ ignore_conflicts=True,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Merkliste (docs/podcasts.md)
+# ---------------------------------------------------------------------------
+
+def _later_add(user, episode_ids, auto=False):
+ """Put episodes on the user's Merkliste (those not on it yet). Like queueing they are no
+ longer new; wanted again after "Folge löschen". Returns the ids added."""
+ have = set(PodcastLater.objects.filter(user=user, episode_id__in=episode_ids).values_list('episode_id', flat=True))
+ new = [i for i in dict.fromkeys(_not_books(episode_ids)) if i not in have]
+ if not new:
+ return []
+ PodcastLater.objects.bulk_create([PodcastLater(user=user, episode_id=i, auto=auto) for i in new], ignore_conflicts=True)
+ _leave_inbox(user, new)
+ EpisodeProgress.objects.filter(user=user, episode_id__in=new, discarded_at__isnull=False).update(discarded_at=None)
+ return new
+
+
+def _later_remove(user, episode_ids):
+ PodcastLater.objects.filter(user=user, episode_id__in=episode_ids).delete()
+
+
+def _later_data(user):
+ """The Merkliste: like the queue's rows (episode__… fields, position_seconds), plus
+ added_at, auto and in_queue (also in the Wiedergabeliste). Newest episode first; the
+ apps group it by podcast."""
+ items = list(
+ PodcastLater.objects.filter(user=user)
+ .order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
+ .values(
+ 'id', 'added_at', 'auto',
+ 'episode__id', 'episode__title', 'episode__audio_url',
+ 'episode__duration_seconds', 'episode__artwork_url',
+ 'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
+ 'episode__pub_date',
+ )
+ )
+ ids = [i['episode__id'] for i in items]
+ positions = dict(EpisodeProgress.objects.filter(user=user, episode_id__in=ids).values_list('episode_id', 'position_seconds'))
+ queued = set(PodcastQueue.objects.filter(user=user, episode_id__in=ids).values_list('episode_id', flat=True))
+ for i in items:
+ i['position_seconds'] = positions.get(i['episode__id'], 0)
+ i['in_queue'] = i['episode__id'] in queued
+ i['added_at'] = i['added_at'].isoformat()
+ if i['episode__pub_date']:
+ i['episode__pub_date'] = i['episode__pub_date'].isoformat()
+ return items
+
+
+# How often a feed is fetched (docs/podcasts.md): fixed, or by its rhythm — a feed that
+# brings an episode every n days is fetched every n hours, within 15 minutes and a day.
+REFRESH_HOURS = {'hourly': 1, 'daily': 24, 'weekly': 24 * 7}
+REFRESH_AUTO_MIN_HOURS = 0.25
+REFRESH_AUTO_MAX_HOURS = 24
+
+
+def refresh_hours(feed, interval_days):
+ """Hours between two fetches of `feed` (a PodcastFeed or a dict with refresh_interval)."""
+ fixed = REFRESH_HOURS.get(getattr(feed, 'refresh_interval', None) if not isinstance(feed, dict) else feed.get('refresh_interval'))
+ if fixed:
+ return fixed
+ if interval_days is None:
+ return 1
+ return min(max(interval_days, REFRESH_AUTO_MIN_HOURS), REFRESH_AUTO_MAX_HOURS)
+
+
+def refresh_due(feeds, now=None):
+ """The ids of the feeds due for a fetch: never fetched, or longer ago than their
+ refresh_hours (with a few minutes' slack for the 15-minute cron)."""
+ now = now or timezone.now()
+ intervals = {}
+ due = []
+ for f in feeds:
+ if f.user_id not in intervals:
+ intervals[f.user_id] = _intervals(f.user)
+ if f.last_refreshed_at is None:
+ due.append(f.id)
+ continue
+ hours = refresh_hours(f, intervals[f.user_id].get(f.id))
+ if now - f.last_refreshed_at >= timedelta(hours=hours) - timedelta(minutes=5):
+ due.append(f.id)
+ return due
# ---------------------------------------------------------------------------
@@ -36,14 +306,375 @@ def _parse_duration(raw):
return 0
+# ---------------------------------------------------------------------------
+# Shownotes, chapters and transcripts of a feed entry
+# ---------------------------------------------------------------------------
+
+def _entry_description(entry):
+ """Shownotes HTML: content:encoded when the feed has it (that's where the
+ full notes usually are), else the description/summary."""
+ for c in entry.get('content') or []:
+ value = (c.get('value') or '').strip()
+ if value:
+ return value[:MAX_DESCRIPTION]
+ return (entry.get('summary') or entry.get('description') or '')[:MAX_DESCRIPTION]
+
+
+def _entry_chapters_url(entry):
+ url = ((entry.get('podcast_chapters') or {}).get('url') or '').strip()
+ return url[:1000] if url.lower().startswith(('http://', 'https://')) else ''
+
+
+def _entry_transcripts(entry):
+ raw = entry.get('podcast_transcripts')
+ if raw is None and entry.get('podcast_transcript'):
+ raw = [entry['podcast_transcript']] # feedparser without feedext
+ transcripts = []
+ for t in raw or []:
+ url = (t.get('url') or '').strip()
+ if not url.lower().startswith(('http://', 'https://')) or len(url) > 1000:
+ continue
+ transcripts.append({
+ 'url': url,
+ 'type': (t.get('type') or '')[:100],
+ 'language': (t.get('language') or '')[:35],
+ })
+ if len(transcripts) >= MAX_TRANSCRIPTS:
+ break
+ return transcripts
+
+
+def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts, renamed=None):
+ """Copy what the feed says now onto `episode`; True if anything changed.
+ `renamed`: its title, date and number as the feed has them now (a feed may correct
+ them; an audiobook's chapter order depends on them).
+
+ Inline (Podlove) chapters win. Without them, fetched chapters (from
+ chapters_url, or from the audio file when there's no URL) stay cached for
+ as long as the URL stays the same; inline chapters going away means a
+ fresh fetch.
+ """
+ changed = []
+ for name, value in (renamed or {}).items():
+ if getattr(episode, name) != value:
+ setattr(episode, name, value)
+ changed.append(name)
+ if episode.description != description:
+ episode.description = description
+ changed.append('description')
+ if episode.transcripts != transcripts:
+ episode.transcripts = transcripts
+ changed.append('transcripts')
+ if psc_chapters:
+ chapters, fetched_at = psc_chapters, None
+ elif chapters_url == episode.chapters_url and episode.chapters_fetched_at:
+ chapters, fetched_at = episode.chapters, episode.chapters_fetched_at
+ else:
+ chapters, fetched_at = [], None
+ if episode.chapters_url != chapters_url:
+ episode.chapters_url = chapters_url
+ changed.append('chapters_url')
+ if episode.chapters != chapters:
+ episode.chapters = chapters
+ changed.append('chapters')
+ if episode.chapters_fetched_at != fetched_at:
+ episode.chapters_fetched_at = fetched_at
+ changed.append('chapters_fetched_at')
+ return bool(changed)
+
+
+EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
+ 'title', 'pub_date', 'episode_number']
+
+
# ---------------------------------------------------------------------------
# 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)
+class FeedFetchError(Exception):
+ """A feed that could not be fetched or is not a feed; str() is the short
+ message stored in PodcastFeed.last_error ("HTTP 404", "timeout", …)."""
+
+
+@dataclass
+class FeedFetch:
+ """What _fetch_feed got. status 200: `parsed` is the feedparser result and
+ etag/last_modified are the new validators ('' when the server sent none).
+ status 304: unchanged since the validators that were sent, parsed is None.
+ final_url is where the content came from after all redirects;
+ permanent_url is the requested URL after following only the 301/308 hops
+ at the start of the redirect chain (== the requested URL without one)."""
+ status: int
+ parsed: object = None
+ etag: str = ''
+ last_modified: str = ''
+ final_url: str = ''
+ permanent_url: str = ''
+ body_hash: str = ''
+
+
+# What changes in a feed on every request without anything in it changing:
+# the build date, comments with a generation time.
+_VOLATILE = re.compile(rb'.*?|', re.S)
+
+
+def _body_hash(content):
+ """Fingerprint of a feed body, without the parts that change on every request."""
+ return hashlib.sha256(_VOLATILE.sub(b'', content)).hexdigest()
+
+
+PERMANENT_REDIRECTS = (301, 308)
+
+
+YOUTUBE_FEED_TRIES = 12
+YOUTUBE_RETRY_STATUS = (404, 500, 502, 503)
+YOUTUBE_RETRY_PAUSE = 0.25 # seconds between tries
+
+
+def _fetch_feed(url, etag='', last_modified='', body_hash='', user_agent='diora podcast refresh'):
+ """Download and parse a feed (with a timeout, unlike feedparser's own
+ fetch). With etag/last_modified it is a conditional GET; with body_hash a
+ body like the last one counts as unchanged too (a 304), unparsed —
+ parsing is what takes the time (seconds for a big feed). Returns a
+ FeedFetch; raises FeedFetchError when there is nothing usable."""
+ headers = {'User-Agent': user_agent}
+ if etag:
+ headers['If-None-Match'] = etag
+ if last_modified:
+ headers['If-Modified-Since'] = last_modified
+ # YouTube's channel feeds answer 404 (or a 5xx) now and then for no reason — in
+ # October 2026 about every other request — and the next request gets the feed: ask
+ # again a few times before calling it an error.
+ tries = YOUTUBE_FEED_TRIES if youtube_mod.is_feed(url) else 1
+ for attempt in range(tries):
+ try:
+ r = requests.get(url, timeout=FEED_TIMEOUT, headers=headers)
+ except requests.Timeout:
+ raise FeedFetchError('timeout')
+ except requests.TooManyRedirects:
+ raise FeedFetchError('too many redirects')
+ except requests.ConnectionError:
+ raise FeedFetchError('connection failed')
+ except requests.RequestException as e:
+ raise FeedFetchError(f'request failed ({type(e).__name__})')
+ if r.status_code not in YOUTUBE_RETRY_STATUS or attempt == tries - 1:
+ break
+ time.sleep(YOUTUBE_RETRY_PAUSE)
+
+ # Each hop's target is the next hop's URL (the last one's is r.url).
+ permanent_url = url
+ targets = [h.url for h in r.history[1:]] + [r.url]
+ for hop, target in zip(r.history, targets):
+ if hop.status_code not in PERMANENT_REDIRECTS:
+ break
+ permanent_url = target
+
+ if r.status_code == 304:
+ if not (etag or last_modified):
+ raise FeedFetchError('HTTP 304') # nothing to be unchanged from
+ return FeedFetch(304, None, etag, last_modified, r.url, permanent_url)
+ if r.status_code >= 400:
+ raise FeedFetchError(f'HTTP {r.status_code}')
+
+ new_hash = _body_hash(r.content)
+ if body_hash and new_hash == body_hash:
+ return FeedFetch(304, None, etag, last_modified, r.url, permanent_url, body_hash)
+
+ parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
+ if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
+ raise FeedFetchError('not a feed')
+ new_etag = r.headers.get('ETag', '')
+ new_modified = r.headers.get('Last-Modified', '')
+ return FeedFetch(
+ r.status_code, parsed,
+ # A validator too long to store is dropped (a truncated one never matches).
+ new_etag if len(new_etag) <= 500 else '',
+ new_modified if len(new_modified) <= 100 else '',
+ r.url, permanent_url, new_hash,
+ )
+
+
+def _validators(feeds):
+ """If-None-Match / If-Modified-Since for one download that serves all
+ `feeds` (several users' subscriptions of the same URL): only when every
+ one of them was fetched before and stored the same validators, because a
+ 304 then skips all of them. Otherwise {} (a full response)."""
+ pairs = {(f.http_etag, f.http_last_modified, f.http_body_hash) for f in feeds}
+ if len(pairs) != 1 or any(f.last_refreshed_at is None for f in feeds):
+ return {}
+ etag, last_modified, body_hash = pairs.pop()
+ out = {'etag': etag, 'last_modified': last_modified} if etag or last_modified else {}
+ if body_hash:
+ out['body_hash'] = body_hash
+ return out
+
+
+def _error_text(e):
+ if isinstance(e, FeedFetchError):
+ return str(e)[:500]
+ return (str(e) or type(e).__name__)[:500]
+
+
+def _record_error(feed_obj, e):
+ feed_obj.last_error = _error_text(e)
+ feed_obj.last_error_at = timezone.now()
+ feed_obj.error_count += 1
+ try:
+ feed_obj.save(update_fields=['last_error', 'last_error_at', 'error_count'])
+ except Exception:
+ log.exception('could not store the refresh error of feed %s', feed_obj.pk)
+
+
+ERROR_FIELDS = ['last_error', 'last_error_at', 'error_count']
+
+
+def _clear_error(feed_obj):
+ feed_obj.last_error = ''
+ feed_obj.last_error_at = None
+ feed_obj.error_count = 0
+
+
+BACKOFF_AFTER = 3 # failures in a row before the hourly job backs off
+BACKOFF_MAX_HOURS = 24
+
+
+def _backoff_hours(error_count):
+ """Hours between hourly-job attempts for a feed failing error_count
+ times in a row: 2, 4, 8, 16, 24 … from the third failure on."""
+ if error_count < BACKOFF_AFTER:
+ return 0
+ return min(2 ** (error_count - 2), BACKOFF_MAX_HOURS)
+
+
+def _in_backoff_q(now=None):
+ """Q for feeds that failed too often to be tried again yet."""
+ now = now or timezone.now()
+ n = BACKOFF_AFTER
+ while _backoff_hours(n) < BACKOFF_MAX_HOURS:
+ n += 1
+ # From n failures on the wait is capped; below, one term per count.
+ q = Q(error_count__gte=n, last_error_at__gt=now - timedelta(hours=BACKOFF_MAX_HOURS))
+ for count in range(BACKOFF_AFTER, n):
+ q |= Q(error_count=count, last_error_at__gt=now - timedelta(hours=_backoff_hours(count)))
+ return q
+
+
+# File endings of an enclosure without a usable type.
+AUDIO_EXTENSIONS = ('.mp3', '.m4a', '.m4b', '.ogg', '.oga', '.opus', '.aac', '.flac', '.wav')
+VIDEO_EXTENSIONS = ('.mp4', '.m4v', '.mov', '.webm')
+
+
+def _refresh_feeds(feeds, full=False, deadline=None):
+ """Refresh many feeds at once: the downloads run in parallel and each
+ feed URL is fetched once, even when several users subscribed to it; each
+ download is written as soon as it's there (the writes one by one, while
+ the others still load). Returns (refreshed, failed, new).
+ `full`: download every feed again (no 304) and take in episodes the
+ reader skipped before (see _apply_feed). `deadline` (time.monotonic()):
+ stop there; feeds not done by then stay as they are."""
+ feeds = list(feeds)
+ refreshed = failed = new = 0
+ by_url = {}
+ for f in feeds:
+ by_url.setdefault(f.rss_url, []).append(f)
+ if not by_url:
+ return 0, 0, 0
+
+ def fetch(url):
+ try:
+ return url, _fetch_feed(url, **({} if full else _validators(by_url[url]))), None
+ except Exception as e: # network, HTTP or parser trouble: report and go on
+ return url, None, e
+
+ pool = ThreadPoolExecutor(max_workers=min(FEED_WORKERS, len(by_url)))
+ futures = [pool.submit(fetch, url) for url in sorted(by_url)]
+ try:
+ wait = None if deadline is None else max(0.0, deadline - time.monotonic())
+ for future in as_completed(futures, timeout=wait):
+ url, fetched, err = future.result()
+ for feed in by_url[url]:
+ if err is not None:
+ failed += 1
+ log.warning('refreshing %s failed: %s', feed.rss_url, err)
+ _record_error(feed, err)
+ continue
+ try:
+ new += _refresh_feed(feed, fetched, full=full)
+ refreshed += 1
+ except Exception as e:
+ failed += 1
+ log.warning('updating %s failed: %s', feed.rss_url, e)
+ except FuturesTimeout:
+ log.info('refresh stopped at its deadline: %d of %d feeds done', refreshed + failed, len(feeds))
+ finally:
+ pool.shutdown(wait=False, cancel_futures=True)
+ return refreshed, failed, new
+
+
+def _refresh_feed(feed_obj, fetched=None, full=False):
+ """Fetch rss_url (unless `fetched`, a FeedFetch, is given) and upsert
+ episodes. Returns count of new episodes. A failure is stored on the feed
+ (last_error, error_count) and raised again."""
+ if fetched is None:
+ try:
+ fetched = _fetch_feed(feed_obj.rss_url, **_validators([feed_obj]))
+ except Exception as e:
+ _record_error(feed_obj, e)
+ raise
+ try:
+ return _apply_feed(feed_obj, fetched, full=full)
+ except Exception as e:
+ _record_error(feed_obj, e)
+ raise
+
+
+def _move_to_permanent_url(feed_obj, new_url):
+ """The feed answered with a permanent redirect: subscribe to the new URL,
+ unless the user already has that one too (then nothing changes)."""
+ if not new_url or new_url == feed_obj.rss_url or len(new_url) > 1000:
+ return
+ try:
+ with transaction.atomic():
+ PodcastFeed.objects.filter(pk=feed_obj.pk).update(rss_url=new_url)
+ except IntegrityError: # the user's other subscription has it
+ return
+ log.info('feed %s moved permanently: %s -> %s', feed_obj.pk, feed_obj.rss_url, new_url)
+ feed_obj.rss_url = new_url
+
+
+def _entry_media_url(entry):
+ """The episode's file: its audio enclosure, else a video one (the players
+ play only its sound). '' when there is neither."""
+ video = ''
+ for enc in entry.get('enclosures', []):
+ url = enc.get('url', '')
+ mime = (enc.get('type') or '').lower()
+ path = urllib.parse.urlsplit(url).path.lower()
+ if mime.startswith('audio/') or path.endswith(AUDIO_EXTENSIONS):
+ return url
+ if not video and (mime.startswith('video/') or path.endswith(VIDEO_EXTENSIONS)):
+ video = url
+ # A YouTube feed has no files: the video's page, whose sound the app finds itself.
+ return video or youtube_mod.video_url(entry)
+
+
+def _apply_feed(feed_obj, fetched, full=False):
+ """Store a fetched feed. `full` (a re-read after the reader learned to
+ take more episodes, e.g. video ones): episodes it finds that came out
+ before the last refresh go into the podcast's list, not into "Neu"."""
+ now = timezone.now()
+ previous_refresh = feed_obj.last_refreshed_at
+ if fetched.status == 304:
+ # Unchanged since the last full download: nothing to parse or write.
+ feed_obj.last_refreshed_at = now
+ _clear_error(feed_obj)
+ feed_obj.save(update_fields=['last_refreshed_at', *ERROR_FIELDS])
+ return 0
+
+ parsed = fetched.parsed
channel = parsed.feed
+ # The first fetch of a new subscription brings the whole back catalogue.
+ first_fetch = feed_obj.last_refreshed_at is None
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]
@@ -56,33 +687,41 @@ def _refresh_feed(feed_obj):
channel.get('itunes_image', {}).get('href', '')
or image.get('href', '')
or image.get('url', '')
+ or feed_obj.artwork_url # a YouTube feed has none: the channel's, found when subscribing
)[:1000]
+ if not feed_obj.artwork_url and youtube_mod.feed_channel_id(channel):
+ feed_obj.artwork_url = youtube_mod.channel_artwork(youtube_mod.feed_channel_id(channel))[:1000]
- feed_obj.last_refreshed_at = timezone.now()
+ feed_obj.last_refreshed_at = now
feed_obj.save()
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
new_count = 0
new_episodes_list = []
+ entries = parsed.entries[:max_ep]
- 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
+ # Episodes we already have, in one query, so shownotes/chapters/transcripts
+ # that changed in the feed can be brought up to date (only those rows).
+ guids = [str(e.get('id') or e.get('guid') or '')[:1000] for e in entries]
+ existing = {
+ ep.guid: ep
+ for ep in feed_obj.episodes.filter(guid__in=[g for g in guids if g]).only('id', 'guid', *EXTRA_FIELDS)
+ }
+ changed_episodes = {}
+
+ for entry in entries:
+ audio_url = _entry_media_url(entry)
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)
+ video = youtube_mod.is_video(audio_url)
+ description = youtube_mod.description_html(entry.get('summary')) if video else _entry_description(entry)
+ chapters_url = _entry_chapters_url(entry)
+ psc_chapters = chapters_mod.from_psc(entry)
+ transcripts = _entry_transcripts(entry)
pub_date = None
if entry.get('published_parsed'):
@@ -96,13 +735,25 @@ def _refresh_feed(feed_obj):
except (ValueError, TypeError):
pass
+ known = existing.get(guid)
+ if known is not None:
+ renamed = {'title': title, 'episode_number': ep_number}
+ if pub_date is not None:
+ renamed['pub_date'] = pub_date
+ if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
+ changed_episodes[known.id] = known
+ continue
+
+ duration_raw = entry.get('itunes_duration', 0)
+ duration_seconds = _parse_duration(duration_raw)
+
season_number = None
try:
season_number = int(entry.get('itunes_season', ''))
except (ValueError, TypeError):
pass
- artwork_url = entry.get('itunes_image', {}).get('href', '')[:1000]
+ artwork_url = (youtube_mod.entry_artwork(entry) if video else entry.get('itunes_image', {}).get('href', ''))[:1000]
episode_obj, created = PodcastEpisode.objects.get_or_create(
feed=feed_obj,
@@ -116,21 +767,82 @@ def _refresh_feed(feed_obj):
'episode_number': ep_number,
'season_number': season_number,
'artwork_url': artwork_url,
+ 'chapters_url': chapters_url,
+ 'chapters': psc_chapters,
+ 'transcripts': transcripts,
},
)
+ existing[guid] = episode_obj # the same guid twice in one feed
if created:
new_count += 1
new_episodes_list.append(episode_obj)
+ elif _apply_extras(episode_obj, description, chapters_url, psc_chapters, transcripts):
+ changed_episodes[episode_obj.id] = episode_obj
- if feed_obj.auto_queue and new_episodes_list:
- count = len(new_episodes_list)
- PodcastQueue.objects.filter(user=feed_obj.user).update(position=F('position') + count)
- for idx, ep_obj in enumerate(new_episodes_list):
- PodcastQueue.objects.get_or_create(
- user=feed_obj.user, episode=ep_obj,
- defaults={'position': idx},
+ if changed_episodes:
+ PodcastEpisode.objects.bulk_update(list(changed_episodes.values()), EXTRA_FIELDS, batch_size=100)
+
+ if full and not first_fetch and new_episodes_list:
+ # Found only now, but out before the last refresh: not news.
+ late = [e for e in new_episodes_list if e.pub_date is None or e.pub_date < previous_refresh]
+ if late:
+ EpisodeProgress.objects.bulk_create(
+ [EpisodeProgress(user=feed_obj.user, episode=e, dismissed=True) for e in late],
+ ignore_conflicts=True,
)
+ gone = {e.id for e in late}
+ new_episodes_list = [e for e in new_episodes_list if e.id not in gone]
+ if first_fetch and len(new_episodes_list) > NEW_ON_SUBSCRIBE:
+ # Only the newest episodes count as new; the rest stay in the
+ # podcast's episode list, out of the inbox (dismissed, not played).
+ newest_first = sorted(new_episodes_list, key=lambda e: e.pub_date or e.discovered_at, reverse=True)
+ older = newest_first[NEW_ON_SUBSCRIBE:]
+ EpisodeProgress.objects.bulk_create(
+ [EpisodeProgress(user=feed_obj.user, episode=e, dismissed=True) for e in older],
+ ignore_conflicts=True,
+ )
+ new_episodes_list = newest_first[:NEW_ON_SUBSCRIBE]
+
+ # The feed's own rules for its new episodes: those its filter turns down,
+ # and all of them for 'ignore', go straight out of the inbox (not played);
+ # 'queue' puts them in the queue (which also takes them out of the inbox);
+ # 'inbox' leaves them there.
+ if new_episodes_list:
+ unwanted = [e for e in new_episodes_list if not feed_obj.matches_filter(e.title, e.duration_seconds)]
+ if feed_obj.new_episodes == PodcastFeed.NEW_IGNORE or feed_obj.kind == PodcastFeed.AUDIOBOOK:
+ unwanted = new_episodes_list # an audiobook's new chapters wait in the library
+ if unwanted:
+ gone = {e.id for e in unwanted}
+ _leave_inbox(feed_obj.user, list(gone))
+ new_episodes_list = [e for e in new_episodes_list if e.id not in gone]
+
+ stamm = (PodcastFeed.NEW_QUEUE, PodcastFeed.NEW_PLAY)
+ if feed_obj.new_episodes in stamm and new_episodes_list and feed_obj.kind != PodcastFeed.AUDIOBOOK:
+ s = PodcastSettings.for_user(feed_obj.user)
+ newest_first = sorted(
+ new_episodes_list,
+ key=lambda e: e.pub_date or e.discovered_at,
+ reverse=True,
+ )
+ if s.lists_v2 and feed_obj.new_episodes == PodcastFeed.NEW_QUEUE:
+ # Stamm: into the Merkliste (docs/podcasts.md).
+ _later_add(feed_obj.user, [e.id for e in newest_first], auto=True)
+ else:
+ where = s.auto_queue_position
+ # Newest first in front of what's there; oldest first when appended,
+ # so the end of the queue plays in the order the episodes came out.
+ ordered = newest_first if where != PodcastSettings.BACK else newest_first[::-1]
+ _enqueue(feed_obj.user, [e.id for e in ordered], where)
+
+ # Only now, with every episode written: the validators (a 304 next time
+ # skips all of the above) and a clean error state.
+ feed_obj.http_etag = fetched.etag
+ feed_obj.http_last_modified = fetched.last_modified
+ feed_obj.http_body_hash = fetched.body_hash
+ _clear_error(feed_obj)
+ feed_obj.save(update_fields=['http_etag', 'http_last_modified', 'http_body_hash', *ERROR_FIELDS])
+ _move_to_permanent_url(feed_obj, fetched.permanent_url)
return new_count
@@ -142,6 +854,15 @@ def podcast_search(request):
q = request.GET.get('q', '').strip()
if not q:
return JsonResponse({'results': []})
+ if youtube_mod.is_youtube(q):
+ try:
+ found = youtube_mod.resolve(q)
+ except youtube_mod.NotAChannel:
+ return JsonResponse({'results': []})
+ return JsonResponse({'results': [{
+ 'id': found['feed'], 'title': found['title'] or 'YouTube', 'author': 'YouTube',
+ 'artwork_url': found['artwork'], 'rss_url': found['feed'], 'genre': 'YouTube',
+ }]})
try:
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
@@ -174,21 +895,108 @@ def feed_list(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
- feeds = list(
- request.user.podcast_feeds
- .annotate(latest_episode_at=Max('episodes__pub_date'))
- .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
- )
+ return JsonResponse({'feeds': _feeds_data(request.user)})
+
+FEED_SETTING_FIELDS = (
+ 'playback_speed', 'skip_intro', 'skip_outro', 'volume',
+ 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download',
+ 'notify', 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', 'folders',
+ 'refresh_interval', 'favorite',
+)
+
+
+def _feed_settings(feed):
+ return {'feed_id': feed.id, **{name: getattr(feed, name) for name in FEED_SETTING_FIELDS}}
+
+
+INTERVAL_EPISODES = 10 # a feed's newest episodes that tell how often it comes
+HEARD_MIN_SECONDS = 60 # listened into this far, an episode counts as heard (with played)
+INTERVAL_SINGLE_DAYS = 30
+
+
+def _intervals(user):
+ """How often each feed brings an episode, in days: the mean gap between its newest
+ INTERVAL_EPISODES. One episode only (or all within an hour): its age, at least INTERVAL_SINGLE_DAYS. The lists
+ put the seldom ones first within a day or week (after Kino's feed; feeds.js does the
+ same for blogs). One query (the newest episodes per feed by a window), kept an hour."""
+ key = f'podcasts:intervals:{user.id}'
+ got = cache.get(key)
+ if got is not None:
+ return got
+ rows = (
+ PodcastEpisode.objects.filter(feed__user=user, pub_date__isnull=False)
+ .annotate(n=Window(RowNumber(), partition_by=[F('feed_id')], order_by=F('pub_date').desc()))
+ .filter(n__lte=INTERVAL_EPISODES)
+ .values_list('feed_id', 'pub_date')
+ )
+ dates = {}
+ for feed_id, when in rows:
+ dates.setdefault(feed_id, []).append(when)
+ now = timezone.now()
+ out = {}
+ for feed_id, ds in dates.items():
+ span = (max(ds) - min(ds)).total_seconds() / 86400
+ if len(ds) == 1 or span < 1 / 24:
+ # One episode, or all within an hour (an archive, a course put up at once): no rhythm
+ # to go by. Hourly news still has one.
+ days = max((now - max(ds)).total_seconds() / 86400, INTERVAL_SINGLE_DAYS)
+ else:
+ days = span / (len(ds) - 1)
+ out[feed_id] = round(days, 2)
+ cache.set(key, out, 3600)
+ return out
+
+
+def _feeds_data(user):
+ """The user's feeds with their settings and, per feed, how many episodes
+ it has, how many of them are unplayed and how many new (as in the inbox:
+ neither played nor dismissed), and when the user last listened to one.
+ Two queries, however many feeds: the feeds with their episode count, and
+ the user's progress rows counted per feed (and, once an hour, how often each
+ feed comes: _intervals)."""
+ feeds = list(
+ user.podcast_feeds
+ .annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
+ .values('id', 'title', 'artwork_url', 'rss_url', 'link', 'last_refreshed_at', 'author', 'added_at',
+ 'latest_episode_at', 'episode_count', 'last_error', 'last_error_at', 'error_count',
+ *FEED_SETTING_FIELDS)
+ )
+ listened = {
+ row['episode__feed_id']: row
+ for row in EpisodeProgress.objects.filter(user=user, episode__feed__user=user)
+ .order_by()
+ .values('episode__feed_id')
+ .annotate(
+ played_count=Count('id', filter=Q(played=True)),
+ hidden_count=Count('id', filter=Q(played=True) | Q(dismissed=True)),
+ last_played_at=Max('updated_at', filter=Q(played=True) | Q(position_seconds__gt=0)),
+ heard_365=Count('id', filter=(Q(played=True) | Q(position_seconds__gt=HEARD_MIN_SECONDS))
+ & Q(updated_at__gte=timezone.now() - timedelta(days=365))),
+ )
+ }
+
+ intervals = _intervals(user)
for f in feeds:
+ f['feed_id'] = f['id'] # as in feeds//settings/
+ f['interval_days'] = intervals.get(f['id'])
+ mine = listened.get(f['id'], {})
+ f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
+ f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
+ # Episodes heard in the last 12 months: the Strom puts the most listened first,
+ # any heard one before every never-heard one (docs/podcasts.md).
+ f['heard_365'] = mine.get('heard_365', 0)
+ last = mine.get('last_played_at')
+ f['last_played_at'] = last.isoformat() if last else None
if f['last_refreshed_at']:
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
+ if f['last_error_at']:
+ f['last_error_at'] = f['last_error_at'].isoformat()
if f['added_at']:
f['added_at'] = f['added_at'].isoformat()
if f['latest_episode_at']:
f['latest_episode_at'] = f['latest_episode_at'].isoformat()
-
- return JsonResponse({'feeds': feeds})
+ return feeds
# ---------------------------------------------------------------------------
@@ -209,11 +1017,22 @@ def add_feed(request):
rss_url = body.get('rss_url', '').strip()
if not rss_url:
return JsonResponse({'error': 'rss_url required'}, status=400)
+ title = body.get('title', rss_url)
+ artwork = ''
+ if youtube_mod.is_youtube(rss_url) and not youtube_mod.is_feed(rss_url):
+ # A channel, playlist or video address: its channel's feed (the server reads only that).
+ try:
+ found = youtube_mod.resolve(rss_url)
+ except youtube_mod.NotAChannel as e:
+ return JsonResponse({'error': f'No YouTube channel there ({e})'}, status=400)
+ rss_url, artwork = found['feed'], found['artwork']
+ title = found['title'] or title
feed, created = PodcastFeed.objects.get_or_create(
user=request.user,
rss_url=rss_url,
- defaults={'title': body.get('title', rss_url)[:300]},
+ defaults={'title': title[:300], 'artwork_url': artwork[:1000],
+ **PodcastSettings.new_feed_defaults(request.user)},
)
new_episodes = 0
@@ -269,11 +1088,226 @@ def set_auto_queue(request, pk):
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
+ # On: new episodes go to the queue; off: back to the inbox (a feed set
+ # to 'ignore' stays so). PodcastFeed.save() sets new_episodes to match.
feed.auto_queue = bool(body.get('auto_queue', not feed.auto_queue))
feed.save(update_fields=['auto_queue'])
return JsonResponse({'ok': True, 'auto_queue': feed.auto_queue})
+# ---------------------------------------------------------------------------
+# Per-feed settings
+# ---------------------------------------------------------------------------
+
+def _number(value):
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
+
+
+def _whole(value, low, high):
+ if not _number(value) or value != int(value) or not low <= value <= high:
+ raise ValueError
+ return int(value)
+
+
+def _speed(value):
+ if value is None:
+ return None
+ if not _number(value) or not MIN_SPEED <= value <= MAX_SPEED:
+ raise ValueError
+ return float(value)
+
+
+def _episode_sort(value):
+ if value in (None, ''):
+ return ''
+ if value not in PodcastFeed.EPISODE_SORTS:
+ raise ValueError
+ return value
+
+
+def _episode_filter(value):
+ """A list of filter ids (each once, in the given order); [] = all episodes."""
+ if value is None:
+ return []
+ if not isinstance(value, list) or any(v not in PodcastFeed.EPISODE_FILTERS for v in value):
+ raise ValueError
+ return list(dict.fromkeys(value))
+
+
+def _optional_boolean(value):
+ if value is not None and not isinstance(value, bool):
+ raise ValueError
+ return value
+
+
+def _volume(value):
+ if not _number(value) or float(value) not in PodcastFeed.VOLUMES:
+ raise ValueError
+ return float(value)
+
+
+def _kind(value):
+ if value not in {key for key, _ in PodcastFeed.KINDS}:
+ raise ValueError
+ return value
+
+
+def _new_episodes(value):
+ if value not in {key for key, _ in PodcastFeed.NEW_EPISODE_ACTIONS}:
+ raise ValueError
+ return value
+
+
+def _refresh_interval(value):
+ if value not in {key for key, _ in PodcastFeed.REFRESH_INTERVALS}:
+ raise ValueError
+ return value
+
+
+def _boolean(value):
+ if not isinstance(value, bool):
+ raise ValueError
+ return value
+
+
+def _word_list(value):
+ if value is None:
+ return ''
+ if not isinstance(value, str) or len(value.strip()) > 500:
+ raise ValueError
+ return value.strip()
+
+
+FEED_SETTING_PARSERS = {
+ 'playback_speed': _speed,
+ 'skip_intro': lambda v: _whole(v, 0, 600),
+ 'skip_outro': lambda v: _whole(v, 0, 600),
+ 'volume': _volume,
+ 'new_episodes': _new_episodes,
+ 'include_words': _word_list,
+ 'exclude_words': _word_list,
+ 'min_duration': lambda v: _whole(v, 0, 600),
+ 'auto_download': _boolean,
+ 'notify': _boolean,
+ 'episode_sort': _episode_sort,
+ 'episode_filter': _episode_filter,
+ 'kind': _kind,
+ 'folder': lambda v: folders_mod.clean_name(v),
+ 'folders': folders_mod.clean_list,
+ 'skip_silence': _optional_boolean,
+ 'refresh_interval': lambda v: _refresh_interval(v),
+ 'favorite': _boolean,
+}
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def move_feed(request, pk):
+ """Give a subscription a new address, keeping it: its settings, episodes, places,
+ played and favourite marks, queue entries. For a podcast that moved, or one that was
+ a YouTube channel fed through some other server (a podsync-like one): moved to the
+ channel itself (a YouTube address of any kind), each episode whose file was named
+ after its video becomes that video (docs/youtube.md), so the channel's feed finds it
+ again instead of adding it twice. {"ok", "rss_url", "videos": episodes made videos,
+ "new_episodes"}."""
+ 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 = PodcastFeed.objects.filter(pk=pk, user=request.user).first()
+ if feed is None:
+ return JsonResponse({'error': 'not found'}, status=404)
+ rss_url = str(body.get('rss_url') or '').strip()
+ if not rss_url.startswith(('http://', 'https://')) or len(rss_url) > 1000:
+ return JsonResponse({'error': 'rss_url required'}, status=400)
+ artwork = ''
+ if youtube_mod.is_youtube(rss_url) and not youtube_mod.is_feed(rss_url):
+ try:
+ found = youtube_mod.resolve(rss_url)
+ except youtube_mod.NotAChannel as e:
+ return JsonResponse({'error': f'No YouTube channel there ({e})'}, status=400)
+ rss_url, artwork = found['feed'], found['artwork']
+ if rss_url != feed.rss_url and PodcastFeed.objects.filter(user=request.user, rss_url=rss_url).exists():
+ return JsonResponse({'error': 'already subscribed at that address'}, status=409)
+
+ videos = 0
+ with transaction.atomic():
+ if youtube_mod.is_feed(rss_url):
+ taken = set(feed.episodes.values_list('guid', flat=True))
+ changed = []
+ for ep in feed.episodes.only('id', 'guid', 'audio_url'):
+ vid = youtube_mod.video_id_in(ep.audio_url)
+ guid = f'yt:video:{vid}' if vid else ''
+ if not vid or (guid in taken and ep.guid != guid):
+ continue # not named after a video, or that video is here already
+ taken.add(guid)
+ ep.guid, ep.audio_url = guid, youtube_mod.WATCH + vid
+ changed.append(ep)
+ PodcastEpisode.objects.bulk_update(changed, ['guid', 'audio_url'], batch_size=200)
+ videos = len(changed)
+ feed.rss_url = rss_url
+ if artwork:
+ feed.artwork_url = artwork[:1000]
+ # Whatever the old address answered says nothing about the new one.
+ feed.http_etag = feed.http_last_modified = feed.http_body_hash = ''
+ feed.save()
+ try:
+ # Videos the old address didn't carry that came out before its last refresh aren't news.
+ new_episodes = _refresh_feed(feed, full=True)
+ except Exception as e: # moved all the same: the next refresh tries again
+ log.warning('feed %s moved, first refresh failed: %s', feed.pk, e)
+ new_episodes = 0
+ return JsonResponse({'ok': True, 'rss_url': feed.rss_url, 'videos': videos, 'new_episodes': new_episodes})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def feed_settings(request, pk):
+ """Change some of one feed's settings (any of FEED_SETTING_PARSERS);
+ returns all of them. Nothing is changed if one value is invalid."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ if not isinstance(body, dict):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ try:
+ feed = PodcastFeed.objects.get(pk=pk, user=request.user)
+ except PodcastFeed.DoesNotExist:
+ return JsonResponse({'error': 'not found'}, status=404)
+
+ changes = {}
+ for name, parse in FEED_SETTING_PARSERS.items():
+ if name in body:
+ try:
+ changes[name] = parse(body[name])
+ except (ValueError, TypeError, OverflowError):
+ return JsonResponse({'error': f'invalid {name}', 'field': name}, status=400)
+ # Folders (docs/library.md): a list; `folder` alone (older clients) means that one only.
+ if 'folders' in changes or 'folder' in changes:
+ names = changes.pop('folders', None)
+ if names is None:
+ one = changes.pop('folder')
+ names = [one] if one else []
+ changes.pop('folder', None)
+ folders_mod.set_folders(feed, names)
+ changes['folders'], changes['folder'] = feed.folders, feed.folder
+ became_book = changes.get('kind') == PodcastFeed.AUDIOBOOK and feed.kind != PodcastFeed.AUDIOBOOK
+ if changes:
+ for name, value in changes.items():
+ setattr(feed, name, value)
+ feed.save(update_fields=list(changes))
+ if became_book: # an audiobook's chapters aren't news (docs/audiobooks.md)
+ _leave_inbox(request.user, list(feed.episodes.values_list('id', flat=True)))
+ # Its order depends on titles and dates: the next refresh reads the feed whole.
+ PodcastFeed.objects.filter(pk=feed.pk).update(http_etag='', http_last_modified='', http_body_hash='')
+ return JsonResponse(_feed_settings(feed))
+
+
# ---------------------------------------------------------------------------
# Feed episodes
# ---------------------------------------------------------------------------
@@ -301,7 +1335,7 @@ def feed_episodes(request, pk):
for ep in EpisodeProgress.objects.filter(
user=request.user,
episode__feed=feed,
- ).values('episode_id', 'position_seconds', 'played')
+ ).values('episode_id', 'position_seconds', 'played', 'favorite')
}
# Batch-fetch queue membership
@@ -318,6 +1352,7 @@ def feed_episodes(request, pk):
prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0)
ep['played'] = prog.get('played', False)
+ ep['favorite'] = prog.get('favorite', False)
ep['in_queue'] = ep['id'] in queued_ids
return JsonResponse({
@@ -327,11 +1362,461 @@ def feed_episodes(request, pk):
'artwork_url': feed.artwork_url,
'author': feed.author,
'auto_queue': feed.auto_queue,
+ # For the podcast's info page.
+ 'description': feed.description,
+ 'link': feed.link,
+ 'rss_url': feed.rss_url,
+ 'last_refreshed_at': feed.last_refreshed_at.isoformat() if feed.last_refreshed_at else None,
},
'episodes': episodes,
})
+# ---------------------------------------------------------------------------
+# One episode in detail: shownotes, chapters, transcripts
+# ---------------------------------------------------------------------------
+
+def _load_chapters(episode):
+ """Fetch the chapters the feed doesn't carry inline, once, and cache them
+ on `episode`: the JSON at chapters_url, else (no URL, or nothing there)
+ the CHAP frames in the ID3v2 tag at the start of the audio file.
+
+ Problems that may go away (timeouts, server errors) aren't cached, so the
+ next request tries again; anything else is remembered as "no chapters".
+ """
+ url = episode.chapters_url
+ chapters = []
+ try:
+ if url:
+ try:
+ chapters = chapters_mod.fetch(url)
+ except chapters_mod.ChaptersInvalid as e:
+ log.info('chapters of episode %s unusable: %s', episode.id, e)
+ if not chapters and episode.audio_url and not youtube_mod.is_video(episode.audio_url):
+ try:
+ chapters = chapters_mod.fetch_id3(episode.audio_url)
+ except chapters_mod.ChaptersInvalid as e:
+ log.info('ID3 chapters of episode %s unusable: %s', episode.id, e)
+ except chapters_mod.ChaptersUnavailable as e:
+ log.info('chapters of episode %s not available: %s', episode.id, e)
+ return []
+ now = timezone.now()
+ # Only if a refresh hasn't changed the URL in the meantime.
+ PodcastEpisode.objects.filter(pk=episode.pk, chapters_url=url).update(
+ chapters=chapters, chapters_fetched_at=now,
+ )
+ episode.chapters, episode.chapters_fetched_at = chapters, now
+ return chapters
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def episode_detail(request, pk):
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ ep = PodcastEpisode.objects.select_related('feed').get(pk=pk, feed__user=request.user)
+ except PodcastEpisode.DoesNotExist:
+ return JsonResponse({'error': 'not found'}, status=404)
+
+ chapters = ep.chapters
+ if not chapters and ep.chapters_fetched_at is None:
+ chapters = _load_chapters(ep)
+
+ progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
+ return JsonResponse({
+ 'id': ep.id,
+ 'title': ep.title,
+ 'description': sanitize_html(ep.description),
+ 'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
+ 'chapters': chapters,
+ 'transcripts': ep.transcripts,
+ 'has_transcript': _has_transcript(ep),
+ 'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
+ 'audio_url': ep.audio_url,
+ 'artwork_url': ep.artwork_url,
+ 'duration_seconds': ep.duration_seconds,
+ 'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
+ 'episode_number': ep.episode_number,
+ 'season_number': ep.season_number,
+ 'position_seconds': progress.position_seconds if progress else 0,
+ 'played': progress.played if progress else False,
+ 'favorite': progress.favorite if progress else False,
+ 'last_played_at': progress.updated_at.isoformat() if progress else None,
+ 'in_queue': PodcastQueue.objects.filter(user=request.user, episode=ep).exists(),
+ })
+
+
+def _transcript_sources(ep):
+ return [t['url'] for t in transcripts_mod.candidates(ep.transcripts)]
+
+
+def _has_transcript(ep):
+ """True if the episode offers a transcript we can read, and it hasn't
+ turned out to be unusable."""
+ sources = _transcript_sources(ep)
+ if not sources:
+ return False
+ cached = EpisodeTranscript.objects.filter(episode=ep).values_list('sources', 'error').first()
+ return not (cached and cached[0] == sources and cached[1])
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def episode_silences(request, pk):
+ """What to cut out of the episode's pauses for skipping silence in the
+ browser: {"ready": true, "v": 2, "spans": [[from, to], …]} once found (the
+ parts to leave out, podcasts/silence.py); the first request starts the search
+ on the server ({"ready": false}, ask again later)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only('id', 'silences', 'audio_url').first()
+ if ep is None:
+ return JsonResponse({'error': 'not found'}, status=404)
+ if youtube_mod.is_video(ep.audio_url): # the server never fetches a video's sound
+ return JsonResponse({'ready': True, 'v': silence_mod.VERSION, 'spans': [], 'error': 'youtube'})
+ if not silence_mod.current(ep.silences): # none yet, or found by older rules
+ silence_mod.ensure(ep.pk)
+ return JsonResponse({'ready': False})
+ return JsonResponse({'ready': True, 'v': silence_mod.VERSION, 'spans': ep.silences.get('spans', []),
+ **({'error': ep.silences['error']} if ep.silences.get('error') else {})})
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def episode_sponsor(request, pk):
+ """What to leave out of the episode's sound: the ad and the outro, from SponsorBlock
+ (sponsor.py). {"ready": true, "v": 1, "spans": [[from, to], …]} — asked for on the
+ first request and then kept on the episode; empty for an episode that isn't a video,
+ and an "error" beside empty spans when SponsorBlock couldn't be asked."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only(
+ 'id', 'sponsor', 'audio_url').first()
+ if ep is None:
+ return JsonResponse({'error': 'not found'}, status=404)
+ return JsonResponse(sponsor_mod.spans_of(ep))
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def episode_transcript(request, pk):
+ """The episode's transcript as {"segments": [{start, end, speaker, text}],
+ "source": {url, type, language}}, fetched and parsed on first use, then
+ kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when
+ there's none to show; 502 {"error": "unavailable"} when it couldn't be
+ loaded right now."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
+ except PodcastEpisode.DoesNotExist:
+ return JsonResponse({'error': 'not found'}, status=404)
+ if not ep.transcripts:
+ return JsonResponse({'error': 'no transcript'}, status=404)
+ sources = _transcript_sources(ep)
+ if not sources:
+ return JsonResponse({'error': 'unsupported'}, status=404)
+
+ cached = EpisodeTranscript.objects.filter(episode=ep).first()
+ if cached is not None and cached.sources == sources:
+ data, error = cached.data, cached.error
+ else:
+ try:
+ data, final = transcripts_mod.load(ep.transcripts)
+ error = ''
+ except chapters_mod.FetchUnavailable as e:
+ log.info('transcript of episode %s not available: %s', ep.id, e)
+ return JsonResponse({'error': 'unavailable'}, status=502)
+ except transcripts_mod.Unsupported as e:
+ log.info('transcript of episode %s unsupported: %s', ep.id, e)
+ data, error, final = None, 'unsupported', True
+ except chapters_mod.FetchInvalid as e:
+ log.info('transcript of episode %s unusable: %s', ep.id, e)
+ data, error, final = None, 'invalid', True
+ if final:
+ EpisodeTranscript.objects.update_or_create(
+ episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
+ )
+ if error:
+ return JsonResponse({'error': error}, status=404)
+ return JsonResponse(data)
+
+
+# ---------------------------------------------------------------------------
+# Favorites
+# ---------------------------------------------------------------------------
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def set_favorite(request, pk):
+ """Star or unstar an episode: {"favorite": true|false}, no value toggles."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ if not isinstance(body, dict):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ try:
+ episode = PodcastEpisode.objects.get(pk=pk, 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)
+ wanted = body.get('favorite')
+ favorite = (not progress.favorite) if wanted is None else bool(wanted)
+ if favorite != progress.favorite:
+ # update() leaves updated_at alone: a star isn't listening, and the
+ # history and the statistics go by updated_at.
+ EpisodeProgress.objects.filter(pk=progress.pk).update(favorite=favorite)
+ return JsonResponse({'ok': True, 'favorite': favorite})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def set_favorites(request):
+ """Star or unstar several episodes at once (choosing several in a list):
+ {"episode_ids": [...], "favorite": true}. Like set_favorite, it leaves
+ the listening time (updated_at) of existing rows alone."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ if not isinstance(body, dict):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ episode_ids = body.get('episode_ids')
+ favorite = body.get('favorite')
+ if not isinstance(episode_ids, list) or not episode_ids or not isinstance(favorite, bool):
+ return JsonResponse({'error': 'episode_ids (a list) and favorite (true/false) required'}, status=400)
+ try:
+ wanted = {int(i) for i in episode_ids[:EPISODE_IDS_MAX]}
+ except (TypeError, ValueError):
+ return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
+ valid = set(
+ PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
+ )
+ with transaction.atomic():
+ rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
+ rows.exclude(favorite=favorite).update(favorite=favorite)
+ if favorite:
+ have = set(rows.values_list('episode_id', flat=True))
+ EpisodeProgress.objects.bulk_create(
+ [EpisodeProgress(user=request.user, episode_id=i, favorite=True) for i in sorted(valid - have)],
+ ignore_conflicts=True,
+ )
+ return JsonResponse({'ok': True, 'favorite': favorite, 'count': len(valid)})
+
+
+# ---------------------------------------------------------------------------
+# Episodes of all feeds: filters, history, search
+# ---------------------------------------------------------------------------
+
+EPISODE_FILTERS = {
+ 'all': Q(),
+ 'unplayed': Q(played=False),
+ 'in_progress': Q(position_seconds__gt=0, played=False),
+ # Neither played nor listened into (the Podcast-Radio's, docs/podcasts.md).
+ 'untouched': Q(position_seconds=0, played=False),
+ 'played': Q(played=True),
+ 'favorites': Q(favorite=True),
+ 'history': Q(played=True) | Q(position_seconds__gt=0),
+ 'queued': Q(in_queue=True),
+ 'later': Q(in_later=True),
+ # The Strom (docs/podcasts.md): every subscription but the "Auf Abruf" ones.
+ 'stream': ~Q(feed__new_episodes=PodcastFeed.NEW_IGNORE),
+ # Episodes of the favourite subscriptions.
+ 'fave_feeds': Q(feed__favorite=True),
+}
+# Episodes of unknown length (0) come last when sorting by length.
+_UNKNOWN_LENGTH_LAST = Case(When(duration_seconds__gt=0, then=Value(0)), default=Value(1), output_field=IntegerField())
+EPISODE_SORTS = {
+ 'new': (F('pub_date').desc(nulls_last=True), '-id'),
+ 'old': (F('pub_date').asc(nulls_last=True), 'id'),
+ 'long': (_UNKNOWN_LENGTH_LAST, '-duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
+ 'short': (_UNKNOWN_LENGTH_LAST, 'duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
+ 'title': (Lower('title'), 'id'),
+ 'played': (F('last_played_at').desc(nulls_last=True), '-id'),
+}
+EPISODE_IDS_MAX = 1000
+EPISODE_LIST_LIMIT = 100
+EPISODE_LIST_MAX = 500
+EPISODE_LIST_FIELDS = (
+ 'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
+ 'feed__id', 'feed__title', 'feed__artwork_url',
+ 'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
+)
+
+
+def _int_param(request, name, default):
+ raw = request.GET.get(name, '').strip()
+ return default if raw == '' else int(raw)
+
+
+def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
+ sort=None, ids=None, notes=False, folder=None, radio=False):
+ """(episodes, total) for the episode list. Two queries, whatever the
+ page size: the count and the page. The user's progress row comes in by
+ a join, queue membership by a subquery. No shownotes (up to 50 KB each).
+
+ filter_name: one of EPISODE_FILTERS, or several (a list, or joined by
+ commas), which all have to match. sort: one of EPISODE_SORTS; by default
+ the newest first, in the history the last listened first. ids: only these
+ episodes (the ones downloaded on a device). notes: `q` also matches the
+ shownotes; title matches come first. folder: only the feeds in this folder ('': the feeds
+ in none). radio: not the ones the Podcast-Radio is done with (PodcastRadioHeard)."""
+ names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
+ names = [n for n in names if n and n != 'all']
+ qs = (
+ PodcastEpisode.objects.filter(feed__user=user)
+ .annotate(mine=FilteredRelation('progress', condition=Q(progress__user=user)))
+ .annotate(
+ position_seconds=Coalesce('mine__position_seconds', Value(0)),
+ played=Coalesce('mine__played', Value(False)),
+ favorite=Coalesce('mine__favorite', Value(False)),
+ last_played_at=F('mine__updated_at'),
+ in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))),
+ in_later=Exists(PodcastLater.objects.filter(user=user, episode=OuterRef('pk'))),
+ )
+ )
+ for name in names:
+ qs = qs.filter(EPISODE_FILTERS[name])
+ if feed_id is not None:
+ qs = qs.filter(feed_id=feed_id)
+ if folder is not None:
+ qs = qs.filter(feed_id__in=[
+ fid for fid, fs in PodcastFeed.objects.filter(user=user).values_list('id', 'folders')
+ if (folder in (fs or []) if folder else not fs)])
+ if ids is not None:
+ qs = qs.filter(id__in=ids)
+ if radio:
+ qs = qs.exclude(Exists(PodcastRadioHeard.objects.filter(user=user, episode=OuterRef('pk'))))
+ if feed_id is None and ids is None: # audiobooks are listened to in the library
+ qs = qs.exclude(feed__kind=PodcastFeed.AUDIOBOOK)
+ order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')]
+ if q and notes:
+ qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q)).annotate(
+ title_hit=Case(When(title__icontains=q, then=Value(0)), default=Value(1), output_field=IntegerField()),
+ )
+ order = ('title_hit', *order)
+ elif q:
+ qs = qs.filter(title__icontains=q)
+ qs = qs.order_by(*order)
+
+ total = qs.count()
+ episodes = list(qs.values(*EPISODE_LIST_FIELDS)[offset:offset + limit]) if limit else []
+ for ep in episodes:
+ ep['pub_date'] = ep['pub_date'].isoformat() if ep['pub_date'] else None
+ ep['last_played_at'] = ep['last_played_at'].isoformat() if ep['last_played_at'] else None
+ ep['played'] = bool(ep['played'])
+ ep['favorite'] = bool(ep['favorite'])
+ ep['in_queue'] = bool(ep['in_queue'])
+ ep['in_later'] = bool(ep['in_later'])
+ ep['discovered_at'] = ep['discovered_at'].isoformat() if ep['discovered_at'] else None
+ return episodes, total
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def episode_list(request):
+ """Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
+ played, favorites, history, queued; several joined by commas all have to
+ match), optionally of one feed, of some episodes (`ids`, comma-separated)
+ or matching `q` (in the title; with notes=1 also in the shownotes), of the
+ feeds in `folder` (empty: in none), sorted by `sort` (new, old, long, short,
+ title, played)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
+ if any(n not in EPISODE_FILTERS for n in names):
+ return JsonResponse({'error': 'unknown filter'}, status=400)
+ sort = request.GET.get('sort', '').strip() or None
+ if sort is not None and sort not in EPISODE_SORTS:
+ return JsonResponse({'error': 'unknown sort'}, status=400)
+ try:
+ offset = max(_int_param(request, 'offset', 0), 0)
+ limit = min(max(_int_param(request, 'limit', EPISODE_LIST_LIMIT), 0), EPISODE_LIST_MAX)
+ feed_id = _int_param(request, 'feed_id', None)
+ raw_ids = request.GET.get('ids')
+ ids = None if raw_ids is None else [int(i) for i in raw_ids.split(',') if i.strip()][:EPISODE_IDS_MAX]
+ except ValueError:
+ return JsonResponse({'error': 'offset, limit, feed_id and ids must be numbers'}, status=400)
+ episodes, total = _episode_list_data(
+ request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
+ sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
+ folder=folders_mod.clean_name(request.GET['folder']) if 'folder' in request.GET else None,
+ )
+ return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})
+
+
+# ---------------------------------------------------------------------------
+# Listening statistics
+# ---------------------------------------------------------------------------
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def podcast_stats(request):
+ """How much the user listened, in total and per feed; `days` limits it to
+ progress rows updated within the last N days."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ rows = EpisodeProgress.objects.filter(user=request.user, episode__feed__user=request.user)
+ since = None
+ raw_days = request.GET.get('days', '').strip()
+ if raw_days:
+ try:
+ days = int(raw_days)
+ except ValueError:
+ return JsonResponse({'error': 'days must be a number'}, status=400)
+ if days < 0:
+ return JsonResponse({'error': 'days must not be negative'}, status=400)
+ since = timezone.now() - timedelta(days=days)
+ rows = rows.filter(updated_at__gte=since)
+
+ # A played episode counts with its full length (or as far as it got, if
+ # the feed gave no length); anything else as far as it got.
+ listened = Case(
+ When(played=True, episode__duration_seconds__gt=0, then=F('episode__duration_seconds')),
+ default=F('position_seconds'),
+ output_field=IntegerField(),
+ )
+ per_feed = (
+ rows.order_by()
+ .values('episode__feed_id', 'episode__feed__title', 'episode__feed__artwork_url')
+ .annotate(
+ seconds=Sum(listened),
+ played_count=Count('id', filter=Q(played=True)),
+ started_count=Count('id', filter=Q(played=False, position_seconds__gt=0)),
+ )
+ )
+ total_seconds = episodes_played = episodes_started = 0
+ feeds = []
+ for row in per_feed:
+ seconds = row['seconds'] or 0
+ total_seconds += seconds
+ episodes_played += row['played_count']
+ episodes_started += row['started_count']
+ if seconds > 0:
+ feeds.append({
+ 'feed_id': row['episode__feed_id'],
+ 'title': row['episode__feed__title'],
+ 'artwork_url': row['episode__feed__artwork_url'],
+ 'seconds': seconds,
+ 'episodes_played': row['played_count'],
+ })
+ feeds.sort(key=lambda f: (-f['seconds'], f['title'].lower(), f['feed_id']))
+ return JsonResponse({
+ 'total_seconds': total_seconds,
+ 'episodes_played': episodes_played,
+ 'episodes_started': episodes_started,
+ 'since': since.isoformat() if since else None,
+ 'feeds': feeds,
+ })
+
+
# ---------------------------------------------------------------------------
# Import OPML
# ---------------------------------------------------------------------------
@@ -354,6 +1839,7 @@ def import_opml(request):
added = 0
skipped = 0
+ new_feed = PodcastSettings.new_feed_defaults(request.user)
for outline in root.iter('outline'):
rss_url = outline.get('xmlUrl', '').strip()
if not rss_url:
@@ -362,7 +1848,7 @@ def import_opml(request):
_, created = PodcastFeed.objects.get_or_create(
user=request.user,
rss_url=rss_url,
- defaults={'title': title},
+ defaults={'title': title, **new_feed},
)
if created:
added += 1
@@ -372,6 +1858,44 @@ def import_opml(request):
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
+# ---------------------------------------------------------------------------
+# Export OPML
+# ---------------------------------------------------------------------------
+
+def _xml_text(s):
+ """Drop characters XML 1.0 can't carry at all (escaping is ElementTree's job)."""
+ return ''.join(c for c in s if c in '\t\n\r' or (' ' <= c <= '') or ('' <= c <= '�')
+ or ord(c) >= 0x10000)
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def export_opml(request):
+ """The user's subscriptions as an OPML 2.0 file, for other podcast apps
+ (and for import_opml)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+
+ root = ET.Element('opml', version='2.0')
+ head = ET.SubElement(root, 'head')
+ ET.SubElement(head, 'title').text = 'diora Podcasts'
+ body = ET.SubElement(root, 'body')
+ for feed in request.user.podcast_feeds.order_by('title', 'id').only('title', 'rss_url', 'link'):
+ title = _xml_text(feed.title or feed.rss_url)
+ ET.SubElement(body, 'outline', {
+ 'type': 'rss',
+ 'text': title,
+ 'title': title,
+ 'xmlUrl': _xml_text(feed.rss_url),
+ 'htmlUrl': _xml_text(feed.link),
+ })
+ ET.indent(root)
+ xml = '\n' + ET.tostring(root, encoding='unicode') + '\n'
+ response = HttpResponse(xml.encode('utf-8'), content_type='text/x-opml; charset=utf-8')
+ response['Content-Disposition'] = 'attachment; filename="diora-podcasts.opml"'
+ return response
+
+
# ---------------------------------------------------------------------------
# Refresh feed now
# ---------------------------------------------------------------------------
@@ -396,6 +1920,9 @@ def refresh_feed_now(request):
except PodcastFeed.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
+ # Asked for by hand: read it whole even if it looks unchanged (validators, body hash),
+ # so corrections to episodes already known come in too.
+ feed.http_etag = feed.http_last_modified = feed.http_body_hash = ''
try:
new_episodes = _refresh_feed(feed)
except Exception as e:
@@ -404,6 +1931,65 @@ def refresh_feed_now(request):
return JsonResponse({'ok': True, 'new_episodes': new_episodes})
+@csrf_exempt
+@require_http_methods(['POST'])
+def refresh_all_feeds(request):
+ """Refresh all of the user's feeds on the server in one go. Feeds the
+ hourly job (or an earlier call) refreshed within `max_age` seconds are
+ skipped. Clients then fetch the result once (state/, inbox/ …).
+ The user asked, so feeds the hourly job backs off from are tried too.
+ It answers within REFRESH_WAIT seconds: `left` feeds weren't done by
+ then (done: false) and wait for the next call or the hourly job."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ try:
+ max_age = max(int(body.get('max_age', 300)), 0)
+ except (TypeError, ValueError):
+ return JsonResponse({'error': 'max_age must be a number'}, status=400)
+ cutoff = timezone.now() - timedelta(seconds=max_age)
+ feeds = list(request.user.podcast_feeds.filter(Q(last_refreshed_at__isnull=True) | Q(last_refreshed_at__lt=cutoff)))
+ refreshed, failed, new = _refresh_feeds(feeds, deadline=time.monotonic() + REFRESH_WAIT)
+ left = len(feeds) - refreshed - failed
+ return JsonResponse({'ok': True, 'refreshed': refreshed, 'failed': failed, 'new_episodes': new,
+ 'done': left == 0, 'left': left})
+
+
+@csrf_exempt
+def podcast_state(request):
+ """Everything a client shows first, in one response: feeds, queue, the
+ first page of the inbox (inbox_limit of inbox_total new episodes), the
+ settings, how many AntennaPod downloads wait to be offered
+ (import-downloads/) and the episodes discarded lately ("Folge löschen":
+ a device deletes its older download of them)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ limit = min(max(int(request.GET.get('inbox_limit', 100)), 0), 1000)
+ except ValueError:
+ return JsonResponse({'error': 'inbox_limit must be a number'}, status=400)
+ s = PodcastSettings.for_user(request.user)
+ downloads = _import_downloads(request.user, s.import_downloads)
+ return JsonResponse({
+ 'feeds': _feeds_data(request.user),
+ 'queue': _queue_data(request.user),
+ 'inbox': _inbox_data(request.user, 0, limit, with_description=False),
+ 'inbox_total': _inbox_episodes(request.user).count(),
+ 'inbox_limit': limit,
+ 'settings': _settings_data(s),
+ # docs/podcasts.md: the Merkliste, whether the two lists are on, the Strom's mark.
+ 'later': _later_data(request.user),
+ 'lists_v2': s.lists_v2,
+ 'stream_seen_at': s.stream_seen_at.isoformat() if s.stream_seen_at else None,
+ 'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)},
+ 'discarded': _discarded_data(request.user),
+ 'server_time': timezone.now().isoformat(),
+ })
+
+
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
@@ -413,15 +1999,21 @@ def queue_get(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
+ return JsonResponse({'queue': _queue_data(request.user)})
+
+
+def _queue_data(user):
items = list(
PodcastQueue.objects
- .filter(user=request.user)
+ .filter(user=user)
+ .order_by('position', 'added_at', 'id')
.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',
+ 'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
+ 'episode__pub_date',
)
)
@@ -429,13 +2021,14 @@ def queue_get(request):
progress_map = {
p['episode_id']: p['position_seconds']
for p in EpisodeProgress.objects.filter(
- user=request.user, episode_id__in=ep_ids,
+ user=user, episode_id__in=ep_ids,
).values('episode_id', 'position_seconds')
}
for item in items:
item['position_seconds'] = progress_map.get(item['episode__id'], 0)
-
- return JsonResponse({'queue': items})
+ if item['episode__pub_date']:
+ item['episode__pub_date'] = item['episode__pub_date'].isoformat()
+ return items
@csrf_exempt
@@ -449,19 +2042,29 @@ def queue_add(request):
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)
+ # One episode (episode_id) or several in order (episode_ids). Where they
+ # go: `where` if given, else the user's setting. `current_episode_id` is
+ # what the client is playing, for after_current.
+ ids = body.get('episode_ids')
+ if ids is None:
+ ids = [body.get('episode_id')]
+ if not isinstance(ids, list):
+ return JsonResponse({'error': 'episode_ids must be a list'}, status=400)
+ where = body.get('where') or PodcastSettings.for_user(request.user).enqueue_position
+ if where not in QUEUE_POSITIONS:
+ return JsonResponse({'error': 'unknown position'}, status=400)
- max_pos = PodcastQueue.objects.filter(user=request.user).count()
- _, created = PodcastQueue.objects.get_or_create(
- user=request.user,
- episode=episode,
- defaults={'position': max_pos},
+ found = set(
+ PodcastEpisode.objects.filter(pk__in=[i for i in ids if isinstance(i, int)], feed__user=request.user)
+ .values_list('id', flat=True)
)
- return JsonResponse({'ok': True, 'created': created})
+ if not found:
+ return JsonResponse({'error': 'not found'}, status=404)
+ added = _enqueue(
+ request.user, [i for i in ids if i in found], where,
+ current_hint=body.get('current_episode_id'),
+ )
+ return JsonResponse({'ok': True, 'created': bool(added), 'added': added})
@csrf_exempt
@@ -475,8 +2078,7 @@ def queue_remove(request):
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()
+ _dequeue(request.user, [body.get('episode_id')])
return JsonResponse({'ok': True})
@@ -494,10 +2096,225 @@ def queue_reorder(request):
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)
+ # A queue that is kept sorted goes straight back into its order.
+ _keep_sorted(request.user)
return JsonResponse({'ok': True})
+def _episode_ids(request):
+ """The user's episodes among body['episode_ids'] (in order), or a JsonResponse error."""
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return None, JsonResponse({'error': 'invalid JSON'}, status=400)
+ ids = body.get('episode_ids') if isinstance(body, dict) else None
+ if not isinstance(ids, list) or not all(isinstance(i, int) and not isinstance(i, bool) for i in ids):
+ return None, JsonResponse({'error': 'episode_ids (a list of numbers) required'}, status=400)
+ found = set(PodcastEpisode.objects.filter(pk__in=ids[:EPISODE_IDS_MAX], feed__user=request.user)
+ .values_list('id', flat=True))
+ return [i for i in ids if i in found], None
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def later_add(request):
+ """{"episode_ids": [...]}: onto the Merkliste (docs/podcasts.md)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ ids, err = _episode_ids(request)
+ if err:
+ return err
+ return JsonResponse({'ok': True, 'added': _later_add(request.user, ids)})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def later_remove(request):
+ """{"episode_ids": [...]}: off the Merkliste (the Wiedergabeliste is left alone)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ ids, err = _episode_ids(request)
+ if err:
+ return err
+ _later_remove(request.user, ids)
+ return JsonResponse({'ok': True})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def lists_switch(request):
+ """{"on": true}: the two lists of docs/podcasts.md — what is in the queue now becomes
+ the Merkliste, the Wiedergabeliste starts empty. {"on": false}: back, the Merkliste
+ joins the end of the queue."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ on = bool(body.get('on')) if isinstance(body, dict) else False
+ user = request.user
+ with transaction.atomic():
+ s = PodcastSettings.for_user(user)
+ if on and not s.lists_v2:
+ _later_add(user, _queue_order(user))
+ PodcastQueue.objects.filter(user=user).delete()
+ elif not on and s.lists_v2:
+ later = list(PodcastLater.objects.filter(user=user)
+ .order_by(F('episode__pub_date').asc(nulls_last=True)).values_list('episode_id', flat=True))
+ _enqueue(user, later, PodcastSettings.BACK)
+ PodcastLater.objects.filter(user=user).delete()
+ s.lists_v2 = on
+ s.save(update_fields=['lists_v2'])
+ return JsonResponse({'ok': True, 'lists_v2': on, 'queue': len(_queue_order(user)),
+ 'later': PodcastLater.objects.filter(user=user).count()})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def stream_seen(request):
+ """The user looked at the Strom now: returns the time before (episodes found after it
+ carry the mark) and keeps now."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ s = PodcastSettings.for_user(request.user)
+ before = s.stream_seen_at
+ s.stream_seen_at = timezone.now()
+ s.save(update_fields=['stream_seen_at'])
+ return JsonResponse({'ok': True, 'before': before.isoformat() if before else None,
+ 'now': s.stream_seen_at.isoformat()})
+
+
+def _choice(choices, what):
+ def parse(value):
+ if value not in choices:
+ raise LookupError(f'unknown {what}')
+ return value
+ return parse
+
+
+def _skip_seconds(value):
+ if value is None:
+ return None
+ if not _number(value) or value != int(value) or int(value) not in PodcastSettings.SKIP_CHOICES:
+ raise ValueError
+ return int(value)
+
+
+def _smart_played(value):
+ return None if value is None else _whole(value, 0, 3600)
+
+
+def _speed_presets(value):
+ """Up to MAX_PRESETS speeds (MIN_SPEED to MAX_SPEED), rounded to two
+ places, each once, ascending; null = the apps' default list."""
+ if value is None:
+ return None
+ if not isinstance(value, list) or not 1 <= len(value) <= PodcastSettings.MAX_PRESETS:
+ raise ValueError
+ out = set()
+ for v in value:
+ if not _number(v) or not math.isfinite(v) or not MIN_SPEED <= v <= MAX_SPEED:
+ raise ValueError
+ out.add(round(float(v), 2))
+ return sorted(out)
+
+
+# POST settings/: each field's parser; a LookupError's text is the error.
+SETTING_PARSERS = {
+ 'enqueue_position': _choice(QUEUE_POSITIONS, 'enqueue_position'),
+ 'auto_queue_position': _choice(QUEUE_POSITIONS, 'auto_queue_position'),
+ 'keep_sorted': _choice(KEEP_SORTED, 'keep_sorted'),
+ 'auto_download_default': _boolean,
+ 'skip_back': _skip_seconds,
+ 'skip_forward': _skip_seconds,
+ 'smart_played': _smart_played,
+ 'speed_presets': _speed_presets,
+ 'keep_skipped': _boolean,
+ 'delete_dequeue': _boolean,
+ 'mobile_downloads': _boolean,
+ 'mobile_streaming': _choice({key for key, _ in PodcastSettings.STREAMING}, 'mobile_streaming'),
+ 'times_by_speed': _boolean,
+}
+
+
+def _settings_data(s):
+ return {
+ 'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position,
+ 'keep_sorted': s.keep_sorted, 'auto_download_default': s.auto_download_default,
+ **{name: getattr(s, name) for name in PodcastSettings.PLAYBACK_FIELDS},
+ }
+
+
+def _parse_settings(body, names):
+ """{field: value} for those of `names` in body; raises (field, error text)."""
+ out = {}
+ for name in names:
+ if name not in body:
+ continue
+ try:
+ out[name] = SETTING_PARSERS[name](body[name])
+ except LookupError as e:
+ raise ValueError(name, str(e))
+ except (ValueError, TypeError, OverflowError):
+ raise ValueError(name, f'invalid {name}')
+ return out
+
+
+@csrf_exempt
+def podcast_settings(request):
+ """GET the user's podcast settings; POST changes some of them (any of
+ SETTING_PARSERS; nothing changes if one value is invalid).
+
+ POST {"initial": {...}} sets the playback settings that are still unset
+ (null) and leaves the others: how an app hands over its own values once,
+ the first time it finds the account without them."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ s = PodcastSettings.for_user(request.user)
+ if request.method == 'POST':
+ try:
+ body = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ if not isinstance(body, dict):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ initial = body.get('initial') or {}
+ if not isinstance(initial, dict):
+ return JsonResponse({'error': 'invalid initial', 'field': 'initial'}, status=400)
+ try:
+ changes = _parse_settings(body, SETTING_PARSERS)
+ first = _parse_settings(initial, PodcastSettings.UNSET_FIELDS)
+ except ValueError as e:
+ name, text = e.args
+ return JsonResponse({'error': text, 'field': name}, status=400)
+ if first:
+ # Only where nothing is set yet, decided by the database (two
+ # devices handing theirs over at once: the first one wins).
+ with transaction.atomic():
+ s = PodcastSettings.objects.select_for_update().get(pk=s.pk)
+ fields = [name for name, value in first.items() if getattr(s, name) is None and value is not None]
+ for name in fields:
+ setattr(s, name, first[name])
+ if fields:
+ s.save(update_fields=fields)
+ if changes:
+ for name, value in changes.items():
+ setattr(s, name, value)
+ s.save(update_fields=list(changes))
+ if 'keep_sorted' in changes:
+ _keep_sorted(request.user, s.keep_sorted) # sorted right away
+ elif request.method != 'GET':
+ return JsonResponse({'error': 'method not allowed'}, status=405)
+ return JsonResponse({
+ **_settings_data(s),
+ 'positions': [{'value': k, 'label': v} for k, v in PodcastSettings.POSITIONS],
+ 'skip_choices': list(PodcastSettings.SKIP_CHOICES),
+ 'speed_range': [MIN_SPEED, MAX_SPEED],
+ })
+
+
# ---------------------------------------------------------------------------
# Progress
# ---------------------------------------------------------------------------
@@ -521,16 +2338,24 @@ def save_progress(request):
except PodcastEpisode.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
+ # A feed without durations (YouTube): the one the player found, once.
+ duration = body.get('duration_seconds')
+ if not episode.duration_seconds and _number(duration) and math.isfinite(duration) and 1 <= duration <= MAX_DURATION:
+ episode.duration_seconds = int(duration)
+ PodcastEpisode.objects.filter(pk=episode.pk, duration_seconds=0).update(duration_seconds=int(duration))
+
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
+ # Started: no longer new.
+ if position > 0:
+ progress.dismissed = True
+ # Within the last seconds: played (same rule as the apps).
+ if counts_as_played(position, episode.duration_seconds):
+ progress.played = True
+ _later_remove(request.user, [episode.id]) # heard: off the Merkliste
progress.save()
return JsonResponse({'ok': True, 'played': progress.played})
@@ -560,7 +2385,13 @@ def mark_played(request):
episode=episode,
)
progress.played = played
- progress.save(update_fields=['played', 'updated_at'])
+ fields = ['played', 'updated_at']
+ if not played and counts_as_played(progress.position_seconds, episode.duration_seconds):
+ progress.position_seconds = 0 # heard to the end, now "not heard": from the start again
+ fields.append('position_seconds')
+ progress.save(update_fields=fields)
+ if played:
+ _later_remove(request.user, [episode.id])
return JsonResponse({'ok': True, 'played': played})
@@ -603,6 +2434,63 @@ def dismiss_episodes(request):
return JsonResponse({'ok': True, 'count': len(valid_ids)})
+# ---------------------------------------------------------------------------
+# "Folge löschen": out of the queue and "Neu", its downloads gone everywhere
+# ---------------------------------------------------------------------------
+
+DISCARDED_DAYS = 30 # how long state/ lists a discard for devices still to delete their copy
+
+
+def _discarded_data(user, now=None):
+ """[{"id": episode id, "at": ISO ms}] of the episodes discarded lately:
+ a device deletes its download of one if it finished before `at`."""
+ since = (now or timezone.now()) - timedelta(days=DISCARDED_DAYS)
+ rows = (EpisodeProgress.objects.filter(user=user, discarded_at__gte=since)
+ .order_by('discarded_at').values_list('episode_id', 'discarded_at'))
+ return [{'id': ep_id, 'at': _iso_ms(at)} for ep_id, at in rows]
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def discard_episodes(request):
+ """{"episode_ids": [...]}: the episodes leave the queue and "Neu" (like
+ dismiss: not played) and every device deletes its download of them (they
+ see them under "discarded" in state/). Played, the saved place and the
+ star stay as they are; the listening time (updated_at) too. Episodes that
+ aren't the user's are left out (count)."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ if not isinstance(body, dict):
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ episode_ids = body.get('episode_ids')
+ if not isinstance(episode_ids, list) or not episode_ids:
+ return JsonResponse({'error': 'episode_ids (a list) required'}, status=400)
+ if not all(isinstance(i, int) and not isinstance(i, bool) for i in episode_ids):
+ return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
+ wanted = set(episode_ids[:EPISODE_IDS_MAX])
+ valid = sorted(
+ PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
+ )
+ now = timezone.now()
+ with transaction.atomic():
+ _dequeue(request.user, valid)
+ _later_remove(request.user, valid)
+ # update() leaves updated_at alone: throwing an episode away isn't listening to it.
+ rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
+ rows.update(dismissed=True, discarded_at=now)
+ have = set(rows.values_list('episode_id', flat=True))
+ EpisodeProgress.objects.bulk_create(
+ [EpisodeProgress(user=request.user, episode_id=i, dismissed=True, discarded_at=now)
+ for i in valid if i not in have],
+ ignore_conflicts=True,
+ )
+ return JsonResponse({'ok': True, 'count': len(valid), 'at': _iso_ms(now)})
+
+
# ---------------------------------------------------------------------------
# Inbox
# ---------------------------------------------------------------------------
@@ -612,39 +2500,32 @@ def inbox(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
- hidden_ids = set(
- EpisodeProgress.objects.filter(
- user=request.user,
- ).filter(
- Q(played=True) | Q(dismissed=True)
- ).values_list('episode_id', flat=True)
- )
-
limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000)
offset = max(int(request.GET.get('offset', 0)), 0)
+ return JsonResponse({'episodes': _inbox_data(request.user, offset, limit), 'offset': offset, 'limit': limit})
- episodes = list(
- PodcastEpisode.objects.filter(feed__user=request.user)
- .exclude(id__in=hidden_ids)
- .select_related('feed')
- .order_by('-pub_date')
- .values(
- 'id', 'title', 'description', 'audio_url', 'duration_seconds',
- 'pub_date', 'artwork_url',
- 'feed__id', 'feed__title', 'feed__artwork_url',
- )[offset:offset + limit]
- )
+def _inbox_fields(with_description=True):
+ fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
+ 'feed__id', 'feed__title', 'feed__artwork_url']
+ if with_description:
+ fields.insert(2, 'description')
+ return fields
+
+
+def _add_user_state(user, episodes):
+ """Complete inbox-style episode dicts (_inbox_fields) with the user's
+ position and whether they are queued."""
progress_map = {
ep['episode_id']: ep
for ep in EpisodeProgress.objects.filter(
- user=request.user,
+ user=user,
episode_id__in=[e['id'] for e in episodes],
).values('episode_id', 'position_seconds')
}
queued_ids = set(
PodcastQueue.objects.filter(
- user=request.user,
+ user=user,
episode_id__in=[e['id'] for e in episodes],
).values_list('episode_id', flat=True)
)
@@ -654,5 +2535,410 @@ def inbox(request):
prog = progress_map.get(ep['id'], {})
ep['position_seconds'] = prog.get('position_seconds', 0)
ep['in_queue'] = ep['id'] in queued_ids
+ return episodes
- return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit})
+
+def _inbox_episodes(user):
+ """The inbox: the user's episodes neither played nor dismissed."""
+ hidden = EpisodeProgress.objects.filter(user=user).filter(
+ Q(played=True) | Q(dismissed=True)
+ ).values('episode_id')
+ return PodcastEpisode.objects.filter(feed__user=user).exclude(id__in=hidden)
+
+
+def _inbox_data(user, offset, limit, with_description=True):
+ """Inbox episodes. Shownotes can be large (up to 50 KB each); the apps'
+ state/ leaves them out and loads one episode's at a time."""
+ episodes = list(
+ _inbox_episodes(user)
+ .select_related('feed')
+ .order_by('-pub_date')
+ .values(*_inbox_fields(with_description))[offset:offset + limit]
+ )
+ return _add_user_state(user, episodes)
+
+
+# ---------------------------------------------------------------------------
+# Episodes downloaded in AntennaPod (antennapod.py): offered once for download
+# ---------------------------------------------------------------------------
+
+def _import_downloads(user, entries, fields=('id',)):
+ """The entries of PodcastSettings.import_downloads still worth offering
+ (the episode is still there, the user's, with an audio URL), in their
+ order: dicts of `fields` plus 'size' (bytes, 0 = unknown)."""
+ sizes = {}
+ for e in entries or []:
+ if isinstance(e, dict) and isinstance(e.get('episode_id'), int):
+ size = e.get('size')
+ sizes.setdefault(e['episode_id'], size if isinstance(size, int) and size > 0 else 0)
+ ids = list(sizes)
+ found = {}
+ for i in range(0, len(ids), 500):
+ for ep in (PodcastEpisode.objects.filter(id__in=ids[i:i + 500], feed__user=user)
+ .exclude(audio_url='').values(*fields)):
+ found[ep['id']] = ep
+ return [{**found[ep_id], 'size': sizes[ep_id]} for ep_id in ids if ep_id in found]
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def import_downloads(request):
+ """The episodes that were on the phone in AntennaPod, inbox-style plus
+ their size; the app offers once to download them again."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ entries = PodcastSettings.objects.filter(user=request.user).values_list('import_downloads', flat=True).first()
+ episodes = _add_user_state(request.user, _import_downloads(request.user, entries, _inbox_fields(False)))
+ return JsonResponse({'episodes': episodes, 'bytes': sum(e['size'] for e in episodes)})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def clear_import_downloads(request):
+ """Offered (downloaded or declined): the list is done with."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ PodcastSettings.objects.filter(user=request.user).update(import_downloads=[])
+ return JsonResponse({'ok': True})
+
+
+# ---------------------------------------------------------------------------
+# Player sync (like Spotify Connect): one player state per user. The device
+# that starts playing becomes the active one and stays it, also while paused,
+# as long as it is online. The others hear it through the stream, stop their
+# own playback and show it as playing (or paused) there, with remote controls
+# (command/), "play here" (a report of their own) and moving it to another
+# device (command transfer). Presence: the stream and every report or command
+# keep the device's last_seen fresh.
+# ---------------------------------------------------------------------------
+
+PLAYER_ONLINE_SECONDS = 45 # not seen for longer: the device is gone
+PLAYER_ACTIONS = ('play', 'pause', 'seek', 'skip', 'next', 'stop', 'transfer', 'sleep')
+SLEEP_MAX_MINUTES = 600 # a sleep timer: 1 min to 10 h, or after 1 to SLEEP_MAX_EPISODES
+SLEEP_MAX_EPISODES = 99
+PLAYER_DEVICES_SHOWN = 10 # online ones, then the last ones seen in DEVICES_DAYS
+PLAYER_DEVICES_DAYS = 7
+STREAM_SECONDS = 300 # one stream connection; clients reconnect with their seq
+STREAM_POLL_SECONDS = 1 # how often the stream looks for a new seq
+STREAM_PING_SECONDS = 15 # a comment to keep the connection, and the device's presence
+STREAM_PRESENCE_SECONDS = 5 # how often the stream looks for devices that went away
+
+
+def _iso_ms(dt):
+ """2026-10-02T12:00:00.123Z"""
+ if dt is None:
+ return None
+ dt = dt.astimezone(dt_timezone.utc)
+ return dt.strftime('%Y-%m-%dT%H:%M:%S.') + f'{dt.microsecond // 1000:03d}Z'
+
+
+def _finite(value):
+ return _number(value) and math.isfinite(value)
+
+
+def _online_since(now):
+ return now - timedelta(seconds=PLAYER_ONLINE_SECONDS)
+
+
+def _online_ids(user, now):
+ return sorted(PlayerDevice.objects.filter(user=user, last_seen__gte=_online_since(now))
+ .values_list('device_id', flat=True))
+
+
+def _seen(user, device_id, device_name='', kind=None, now=None):
+ """The device is here (stream, ping, report, command)."""
+ if not device_id:
+ return
+ now = now or timezone.now()
+ fields = {'last_seen': now}
+ if device_name:
+ fields['device_name'] = device_name[:100]
+ if kind in {k for k, _ in PlayerDevice.KINDS if k}:
+ fields['kind'] = kind
+ # Single statements, no read-then-write transaction (update_or_create):
+ # under SQLite two of those at the same time end in "database is locked".
+ rows = PlayerDevice.objects.filter(user=user, device_id=device_id[:64])
+ if not rows.update(**fields):
+ PlayerDevice.objects.bulk_create([PlayerDevice(user=user, device_id=device_id[:64], **fields)],
+ ignore_conflicts=True)
+ rows.update(**fields) # someone else was quicker
+
+
+def _check_online(user, now=None):
+ """A device came or went: seq + 1, so the others see it. Returns the state."""
+ now = now or timezone.now()
+ online = _online_ids(user, now)
+ p = PlayerState.for_user(user)
+ if p.online_devices == online:
+ return p
+ return _change(p, online_devices=online)
+
+
+def _change(p, **fields):
+ """Write fields of the state and count the change: seq + 1 in the same
+ UPDATE, so that it only ever goes up, also with requests at the same
+ time. Returns the state as it is now."""
+ PlayerState.objects.filter(pk=p.pk).update(seq=F('seq') + 1, **fields)
+ p.refresh_from_db()
+ return p
+
+
+def _is_online(user, device_id, now):
+ return bool(device_id) and PlayerDevice.objects.filter(
+ user=user, device_id=device_id, last_seen__gte=_online_since(now)).exists()
+
+
+def _devices_json(user, p, now):
+ """Online devices first (by name), then the last ones seen lately."""
+ since = _online_since(now)
+ rows = list(PlayerDevice.objects.filter(user=user, last_seen__gte=now - timedelta(days=PLAYER_DEVICES_DAYS))
+ .order_by('-last_seen').values('device_id', 'device_name', 'last_seen'))
+ online = sorted((r for r in rows if r['last_seen'] >= since), key=lambda r: (r['device_name'].casefold(), r['device_id']))
+ offline = [r for r in rows if r['last_seen'] < since]
+ return [
+ {'id': r['device_id'], 'name': r['device_name'], 'online': r['last_seen'] >= since,
+ 'active': r['device_id'] == p.device_id}
+ for r in (online + offline)[:PLAYER_DEVICES_SHOWN]
+ ]
+
+
+def _player_json(user, p, now=None):
+ now = now or timezone.now()
+ episode = None
+ if p.kind == PlayerState.PODCAST and p.episode_id:
+ rows = list(PodcastEpisode.objects.filter(pk=p.episode_id).values(*_inbox_fields(False)))
+ episode = _add_user_state(user, rows)[0] if rows else None
+ station = None
+ if p.kind == PlayerState.RADIO:
+ station = {'url': p.station_url, 'name': p.station_name, 'id': p.station_id}
+ devices = _devices_json(user, p, now)
+ return {
+ 'seq': p.seq,
+ 'kind': p.kind,
+ 'episode': episode,
+ 'podcast_radio': bool(episode) and p.podcast_radio,
+ 'station': station,
+ 'position_ms': p.position_ms,
+ 'playing': p.playing,
+ 'speed': p.speed,
+ 'device_id': p.device_id,
+ 'device_name': p.device_name,
+ 'updated_at': _iso_ms(p.updated_at),
+ 'server_time': _iso_ms(now),
+ 'command': p.command,
+ 'sleep': {'left_ms': p.sleep_left_ms, 'episodes': p.sleep_episodes},
+ 'devices': devices,
+ 'active_online': _is_online(user, p.device_id, now),
+ }
+
+
+def _player_body(request):
+ """The JSON body and the sending device, or an error response."""
+ try:
+ body = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return None, JsonResponse({'error': 'invalid JSON'}, status=400)
+ if not isinstance(body, dict):
+ return None, JsonResponse({'error': 'invalid JSON'}, status=400)
+ device_id = body.get('device_id')
+ if not isinstance(device_id, str) or not device_id.strip() or len(device_id) > 64:
+ return None, JsonResponse({'error': 'device_id required'}, status=400)
+ body['device_name'] = str(body.get('device_name') or '')[:100]
+ return body, None
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def player_state(request):
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ return JsonResponse(_player_json(request.user, PlayerState.for_user(request.user)))
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def player_report(request):
+ """A device says what it plays. Accepted from the active device; from
+ another one when playback was just started there (start: by the user, a
+ command, a transfer — it becomes the active device), or when the active
+ device is gone (offline, or there is none): then the newest report wins.
+ Otherwise ignored, such as a periodic "playing" report of a device that
+ was just taken over. The answer is the state either way."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ body, error = _player_body(request)
+ if error:
+ return error
+
+ kind = body.get('kind') or ''
+ if kind not in {k for k, _ in PlayerState.KINDS}:
+ return JsonResponse({'error': 'unknown kind'}, status=400)
+ playing = body.get('playing')
+ if not isinstance(playing, bool):
+ return JsonResponse({'error': 'playing must be true or false'}, status=400)
+ position = body.get('position_ms', 0)
+ if not _finite(position):
+ return JsonResponse({'error': 'position_ms must be a number'}, status=400)
+ speed = body.get('speed')
+ if speed is None:
+ speed = 1
+ if not _finite(speed) or not 0.1 <= speed <= 10:
+ return JsonResponse({'error': 'invalid speed'}, status=400)
+
+ episode_id = None
+ station = ('', '', None)
+ if kind == PlayerState.PODCAST:
+ episode_id = body.get('episode_id')
+ if not _number(episode_id) or episode_id != int(episode_id) or not \
+ PodcastEpisode.objects.filter(pk=int(episode_id), feed__user=request.user).exists():
+ return JsonResponse({'error': 'unknown episode'}, status=400)
+ episode_id = int(episode_id)
+ elif kind == PlayerState.RADIO:
+ st = body.get('station')
+ if not isinstance(st, dict) or not isinstance(st.get('url'), str) or not st['url'].strip():
+ return JsonResponse({'error': 'station.url required'}, status=400)
+ sid = st.get('id')
+ station = (st['url'].strip()[:1000], str(st.get('name') or '')[:300],
+ int(sid) if _number(sid) and sid == int(sid) else None)
+
+ # The sleep timer, if the device has one (older clients don't say: none).
+ sleep = body.get('sleep') if isinstance(body.get('sleep'), dict) else {}
+ sleep_left = sleep.get('left_ms', 0)
+ sleep_left = min(int(sleep_left), SLEEP_MAX_MINUTES * 60_000) if _finite(sleep_left) and sleep_left > 0 else 0
+ sleep_episodes = sleep.get('episodes', 0)
+ sleep_episodes = min(int(sleep_episodes), SLEEP_MAX_EPISODES) if _finite(sleep_episodes) and sleep_episodes > 0 else 0
+
+ device_id = body['device_id']
+ done = body.get('done_command')
+ start = body.get('start') is True
+ now = timezone.now()
+ _seen(request.user, device_id, body['device_name'], now=now)
+ p = PlayerState.for_user(request.user)
+ done = bool(done) and bool(p.command) and done == p.command.get('id')
+ if device_id == p.device_id or (playing and start) or not _is_online(request.user, p.device_id, now):
+ p = _change(
+ p,
+ kind=kind,
+ episode_id=episode_id,
+ # The episode plays in the Podcast-Radio (docs/podcasts.md).
+ podcast_radio=kind == PlayerState.PODCAST and body.get('podcast_radio') is True,
+ station_url=station[0], station_name=station[1], station_id=station[2],
+ position_ms=max(int(position), 0) if kind == PlayerState.PODCAST else 0,
+ playing=playing and bool(kind),
+ speed=float(speed),
+ sleep_left_ms=sleep_left,
+ sleep_episodes=sleep_episodes,
+ device_id=device_id,
+ device_name=body['device_name'],
+ updated_at=now,
+ # Done (also a transfer, done in the report that makes its target
+ # the active device), or meant for the device before.
+ command=None if done or device_id != p.device_id else p.command,
+ )
+ elif done:
+ # A device that isn't the active one did its command (a transfer
+ # that couldn't play): done all the same.
+ p = _change(p, command=None)
+ p = _check_online(request.user, now)
+ return JsonResponse(_player_json(request.user, p, now))
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def player_command(request):
+ """Remote control: a command for a device (to_device_id, else the active
+ one), which carries it out and reports it done (done_command). Only to a
+ device that is online. transfer: the device plays the shared item from
+ its place and so becomes the active one. sleep: its sleep timer, value =
+ minutes of playing time, -n = after n episodes, 0 = off."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ body, error = _player_body(request)
+ if error:
+ return error
+ action = body.get('action')
+ if action not in PLAYER_ACTIONS:
+ return JsonResponse({'error': 'unknown action'}, status=400)
+ value = None
+ if action in ('seek', 'skip'):
+ value = body.get('value')
+ if not _finite(value):
+ return JsonResponse({'error': 'value must be a number'}, status=400)
+ value = max(int(value), 0) if action == 'seek' else int(value)
+ elif action == 'sleep':
+ value = body.get('value')
+ if not _finite(value) or not -SLEEP_MAX_EPISODES <= int(value) <= SLEEP_MAX_MINUTES:
+ return JsonResponse({'error': f'value must be minutes (up to {SLEEP_MAX_MINUTES}), '
+ f'-episodes or 0'}, status=400)
+ value = int(value)
+ to = body.get('to_device_id')
+ if to is not None and (not isinstance(to, str) or len(to) > 64):
+ return JsonResponse({'error': 'invalid to_device_id'}, status=400)
+
+ now = timezone.now()
+ _seen(request.user, body['device_id'], body['device_name'], now=now)
+ p = _check_online(request.user, now)
+ to = to or p.device_id
+ if not to or not _is_online(request.user, to, now):
+ return JsonResponse({'error': 'device not online'}, status=409)
+ if action == 'transfer' and not p.kind:
+ return JsonResponse({'error': 'nothing to transfer'}, status=409)
+ p = _change(p, command={
+ 'id': uuid.uuid4().hex,
+ 'action': action,
+ 'value': value,
+ 'from_device_id': body['device_id'],
+ 'from_device_name': body['device_name'],
+ 'to_device_id': to,
+ 'at': _iso_ms(now),
+ })
+ return JsonResponse(_player_json(request.user, p, now))
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def player_stream(request):
+ """Server-sent events: the state at once if it is newer than ?since=,
+ then on every change; a ping every 15 s, and the end after a few minutes
+ (the client reconnects with the last seq it has). With device_id (and
+ device_name, device_kind app|web) the device counts as online meanwhile."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ since = int(request.GET.get('since', -1))
+ except ValueError:
+ return JsonResponse({'error': 'since must be a number'}, status=400)
+ user = request.user
+ device_id = request.GET.get('device_id', '')[:64]
+ _seen(user, device_id, request.GET.get('device_name', ''), request.GET.get('device_kind'))
+ _check_online(user)
+
+ def events():
+ last = since
+ first = True
+ start = pinged = checked = time.monotonic()
+ while True:
+ seq = PlayerState.objects.filter(user=user).values_list('seq', flat=True).first()
+ if seq is not None and (seq > last if first else seq != last):
+ p = PlayerState.objects.filter(user=user).first()
+ last = p.seq
+ yield f'data: {json.dumps(_player_json(user, p))}\n\n'
+ elif first:
+ yield ': ping\n\n' # the headers go out now: the client knows it's connected
+ first = False
+ now = time.monotonic()
+ if now - start >= STREAM_SECONDS:
+ return
+ if now - pinged >= STREAM_PING_SECONDS:
+ pinged = now
+ _seen(user, device_id)
+ yield ': ping\n\n'
+ if now - checked >= STREAM_PRESENCE_SECONDS:
+ checked = now
+ _check_online(user)
+ time.sleep(STREAM_POLL_SECONDS)
+
+ response = StreamingHttpResponse(events(), content_type='text/event-stream')
+ response['Cache-Control'] = 'no-cache'
+ response['X-Accel-Buffering'] = 'no'
+ return response
diff --git a/podcasts/youtube.py b/podcasts/youtube.py
new file mode 100644
index 0000000..d15fe6c
--- /dev/null
+++ b/podcasts/youtube.py
@@ -0,0 +1,188 @@
+"""YouTube channels (and playlists) as podcasts.
+
+The server only reads what YouTube publishes about a channel: its page once,
+when it's subscribed (feed address, name, picture), and from then on its RSS
+feed like any podcast's. It never fetches a video or its sound: an episode's
+address is the video's page (https://www.youtube.com/watch?v=…), and each app
+finds the audio stream for it itself and plays only that (docs/youtube.md).
+Hence no skipping of silence and no ID3 chapters found on the server for them.
+
+Shorts are left out: a minute of sound cut from a vertical video isn't an
+episode.
+"""
+import html
+import re
+import urllib.parse
+
+import requests
+
+WATCH = 'https://www.youtube.com/watch?v='
+FEED = 'https://www.youtube.com/feeds/videos.xml'
+HOSTS = {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtu.be'}
+TIMEOUT = 15
+MAX_PAGE = 4 * 1024 * 1024
+# Without this, a server in the EU gets the cookie consent page instead (SOCS=CAI: declined).
+COOKIES = {'SOCS': 'CAI'}
+HEADERS = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0',
+ 'Accept-Language': 'de,en;q=0.8'}
+
+_CHANNEL_ID = re.compile(r'^UC[\w-]{22}$')
+_VIDEO_ID = re.compile(r'^[\w-]{11}$')
+
+
+# Where a page names its own channel, surest first: a channel page in its head, a video's in
+# its videoDetails.
+_OWN_CHANNEL = (
+ r'"]+)')
+
+
+def description_html(text):
+ """A video description (plain text) as shownotes: links clickable, lines kept."""
+ escaped = html.escape(text or '', quote=False)
+ linked = _LINK.sub(lambda m: f'{m.group(1)}', escaped)
+ return '
' + linked.strip().replace('\n\n', '
').replace('\n', ' ') + '
' if linked.strip() else ''
+
+
+def _page(url):
+ r = requests.get(url, headers=HEADERS, cookies=COOKIES, timeout=TIMEOUT, stream=True)
+ try:
+ if r.status_code >= 400:
+ raise NotAChannel(f'HTTP {r.status_code}')
+ body = r.raw.read(MAX_PAGE, decode_content=True)
+ finally:
+ r.close()
+ return body.decode('utf-8', 'replace')
+
+
+def _meta(page, prop):
+ m = re.search(r' 1 and _CHANNEL_ID.match(segs[1]):
+ page_url = f'https://www.youtube.com/channel/{segs[1]}'
+ elif parts.hostname == 'youtu.be' and segs:
+ page_url = WATCH + segs[0]
+ elif segs and (segs[0].startswith('@') or segs[0] in ('c', 'user', 'watch', 'shorts', 'live')):
+ page_url = urllib.parse.urlunsplit(('https', 'www.youtube.com', parts.path, parts.query, ''))
+ else:
+ raise NotAChannel('not a channel address')
+ try:
+ page = _page(page_url)
+ except requests.RequestException as e:
+ raise NotAChannel(type(e).__name__)
+ # The page's own channel, never the first one named (featured channels come earlier).
+ m = None
+ for pattern in _OWN_CHANNEL:
+ m = re.search(pattern, page)
+ if m:
+ break
+ if not m:
+ raise NotAChannel('no channel found')
+ is_channel_page = not (segs and segs[0] in ('watch', 'shorts', 'live')) and parts.hostname != 'youtu.be'
+ return {
+ 'feed': channel_feed(m.group(1)),
+ # A video's page is about the video: its channel's name and picture come with the feed.
+ 'title': _meta(page, 'og:title') if is_channel_page else '',
+ 'artwork': _meta(page, 'og:image') if is_channel_page else '',
+ }
+
+
+def feed_channel_id(channel):
+ """The channel a YouTube feed (feedparser's .feed) belongs to, '' for another feed."""
+ for url in (channel.get('link') or '', (channel.get('author_detail') or {}).get('href') or ''):
+ m = re.match(r'^https://www\.youtube\.com/channel/(UC[\w-]{22})$', url)
+ if m:
+ return m.group(1)
+ return ''
+
+
+def channel_artwork(channel_id):
+ """The channel's picture (the feed has none), '' if it can't be had."""
+ try:
+ return _meta(_page(f'https://www.youtube.com/channel/{channel_id}'), 'og:image')
+ except (requests.RequestException, NotAChannel):
+ return ''
diff --git a/projects/__init__.py b/projects/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/projects/apps.py b/projects/apps.py
new file mode 100644
index 0000000..afae498
--- /dev/null
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ProjectsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'projects'
diff --git a/projects/management/__init__.py b/projects/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/projects/management/commands/__init__.py b/projects/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/projects/management/commands/radar.py b/projects/management/commands/radar.py
new file mode 100644
index 0000000..0b9ff6f
--- /dev/null
+++ b/projects/management/commands/radar.py
@@ -0,0 +1,47 @@
+"""The Radars (projects/radar.py): collect finished batches, submit what is due.
+Run from the host's crontab every half hour:
+ docker exec diora-prod python manage.py radar
+ ... radar --setup neuropolitik a project gets a Radar from a preset
+ ... radar --import a digest made elsewhere (also marks its papers seen)
+"""
+import json
+
+from django.core.management.base import BaseCommand, CommandError
+
+from projects import radar
+from projects.models import Project, Radar
+from projects.radar_presets import PRESETS
+
+
+class Command(BaseCommand):
+ help = 'Radar: collect finished batches, submit what is due'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--setup', nargs=2, metavar=('PROJECT', 'PRESET'))
+ parser.add_argument('--import', dest='import_', nargs=2, metavar=('PROJECT', 'FILE'))
+ parser.add_argument('--seen', nargs=2, metavar=('PROJECT', 'FILE'), help='ids reported before (JSON list)')
+
+ def handle(self, *args, setup=None, import_=None, seen=None, **opts):
+ if setup:
+ p = Project.objects.filter(pk=setup[0]).first()
+ if not p or setup[1] not in PRESETS:
+ raise CommandError('project or preset unknown')
+ r, made = Radar.objects.update_or_create(project=p, defaults={'config': PRESETS[setup[1]]})
+ self.stdout.write(f"radar {p.id}: {'set up' if made else 'config updated'}")
+ return
+ if import_:
+ r = Radar.objects.get(project_id=import_[0])
+ with open(import_[1], encoding='utf-8') as f:
+ d = radar.store(r, json.load(f))
+ self.stdout.write(f'radar {r.project_id}: digest {d.id} ({d.generated})')
+ return
+ if seen:
+ r = Radar.objects.get(project_id=seen[0])
+ with open(seen[1], encoding='utf-8') as f:
+ ids = json.load(f)
+ r.seen = list(dict.fromkeys(list(r.seen) + list(ids)))[-radar.SEEN_MAX:]
+ r.save(update_fields=['seen'])
+ self.stdout.write(f'radar {r.project_id}: {len(r.seen)} seen')
+ return
+ for line in radar.tick():
+ self.stdout.write(line)
diff --git a/projects/migrations/0001_initial.py b/projects/migrations/0001_initial.py
new file mode 100644
index 0000000..176730f
--- /dev/null
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,52 @@
+# Generated by Django 6.1 on 2026-10-19 18:00
+
+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='Project',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('preset', models.CharField(choices=[('research', 'Forschung')], default='research', max_length=20)),
+ ('data_ct', models.TextField()),
+ ('data_iv', models.CharField(max_length=32)),
+ ('archived', models.BooleanField(default=False)),
+ ('position', models.IntegerField(default=0)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['position', '-updated_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ProjectItem',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('kind', models.CharField(choices=[('book', 'Buch'), ('episode', 'Folge'), ('article', 'Artikel'), ('paper', 'Paper'), ('note', 'Notiz')], max_length=10)),
+ ('ref', models.BigIntegerField(blank=True, null=True)),
+ ('data_ct', models.TextField()),
+ ('data_iv', models.CharField(max_length=32)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='projects.project')),
+ ],
+ options={
+ 'ordering': ['created_at'],
+ 'indexes': [models.Index(fields=['kind', 'ref'], name='projects_pr_kind_3ad4df_idx')],
+ 'constraints': [models.UniqueConstraint(condition=models.Q(('ref__isnull', False)), fields=('project', 'kind', 'ref'), name='project_item_once')],
+ },
+ ),
+ ]
diff --git a/projects/migrations/0002_radar.py b/projects/migrations/0002_radar.py
new file mode 100644
index 0000000..733afb0
--- /dev/null
+++ b/projects/migrations/0002_radar.py
@@ -0,0 +1,46 @@
+# Generated by Django 6.1 on 2026-10-19 18:32
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='projectitem',
+ name='kind',
+ field=models.CharField(choices=[('book', 'Buch'), ('episode', 'Folge'), ('article', 'Artikel'), ('paper', 'Paper'), ('note', 'Notiz'), ('radar', 'Radar')], max_length=10),
+ ),
+ migrations.CreateModel(
+ name='Radar',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('enabled', models.BooleanField(default=True)),
+ ('config', models.JSONField(default=dict)),
+ ('seen', models.JSONField(default=list)),
+ ('pending', models.JSONField(blank=True, null=True)),
+ ('last_run_at', models.DateTimeField(blank=True, null=True)),
+ ('last_error', models.TextField(blank=True)),
+ ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='radar', to='projects.project')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='RadarDigest',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('generated', models.DateField()),
+ ('batch_id', models.CharField(blank=True, max_length=100)),
+ ('bundle', models.JSONField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('radar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='digests', to='projects.radar')),
+ ],
+ options={
+ 'ordering': ['-generated', '-created_at'],
+ },
+ ),
+ ]
diff --git a/projects/migrations/__init__.py b/projects/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/projects/models.py b/projects/models.py
new file mode 100644
index 0000000..6c537e5
--- /dev/null
+++ b/projects/models.py
@@ -0,0 +1,81 @@
+"""Projekte (docs/projects.md): a project of a preset (Forschung first), its sources and notes.
+
+End-to-end encrypted like the books: a project's name and settings, and every item's content
+(a note's text, a source's citation data, a remark) are AES-GCM blobs the server can't read.
+What it knows: that a project exists, its preset, and which book, episode or article an item
+points to (kind + ref) — the ids, never their titles."""
+from django.contrib.auth.models import User
+from django.db import models
+
+
+class Project(models.Model):
+ RESEARCH = 'research'
+ PRESETS = [(RESEARCH, 'Forschung')]
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='projects')
+ preset = models.CharField(max_length=20, choices=PRESETS, default=RESEARCH)
+ data_ct = models.TextField() # {name, description, …} encrypted
+ data_iv = models.CharField(max_length=32)
+ archived = models.BooleanField(default=False)
+ position = models.IntegerField(default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['position', '-updated_at']
+
+
+class ProjectItem(models.Model):
+ """Something in a project. Sources point at what's elsewhere in diora (a book, an episode,
+ an article: ref is its id) or stand alone (a paper known by its citation data: no ref);
+ notes are text of their own (no ref)."""
+ BOOK = 'book'
+ EPISODE = 'episode'
+ ARTICLE = 'article'
+ PAPER = 'paper'
+ NOTE = 'note'
+ # The Radar's decisions ({paper id: read | later | drop}), one per project.
+ RADAR = 'radar'
+ KINDS = [(BOOK, 'Buch'), (EPISODE, 'Folge'), (ARTICLE, 'Artikel'), (PAPER, 'Paper'), (NOTE, 'Notiz'),
+ (RADAR, 'Radar')]
+ LINKED = (BOOK, EPISODE, ARTICLE)
+
+ project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='items')
+ kind = models.CharField(max_length=10, choices=KINDS)
+ ref = models.BigIntegerField(null=True, blank=True)
+ data_ct = models.TextField()
+ data_iv = models.CharField(max_length=32)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['created_at']
+ constraints = [models.UniqueConstraint(fields=['project', 'kind', 'ref'], name='project_item_once',
+ condition=models.Q(ref__isnull=False))]
+ indexes = [models.Index(fields=['kind', 'ref'])]
+
+
+class Radar(models.Model):
+ """A project's Radar (docs/projects.md): every week new papers from PubMed, arXiv, bioRxiv,
+ medRxiv (projects/papers.py), sorted and summed up by Claude in a batch (projects/radar.py).
+ Plain on the server, unlike the rest of a project: the server searches with the config and
+ the papers are public."""
+ project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name='radar')
+ enabled = models.BooleanField(default=True)
+ config = models.JSONField(default=dict) # projects/radar_presets.py
+ seen = models.JSONField(default=list) # paper ids reported before
+ pending = models.JSONField(null=True, blank=True) # {batch_id, generated, days, records, errors, submitted}
+ last_run_at = models.DateTimeField(null=True, blank=True)
+ last_error = models.TextField(blank=True)
+
+
+class RadarDigest(models.Model):
+ """One week's digest: the bundle as digest_batch.py makes it (version 1)."""
+ radar = models.ForeignKey(Radar, on_delete=models.CASCADE, related_name='digests')
+ generated = models.DateField()
+ batch_id = models.CharField(max_length=100, blank=True)
+ bundle = models.JSONField()
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-generated', '-created_at']
diff --git a/projects/papers.py b/projects/papers.py
new file mode 100644
index 0000000..e583ba3
--- /dev/null
+++ b/projects/papers.py
@@ -0,0 +1,143 @@
+"""New papers for a project's Radar (docs/projects.md): PubMed, arXiv, bioRxiv, medRxiv.
+
+What is searched comes from the radar's config (projects/radar_presets.py): PubMed queries per
+topic, one arXiv query, the preprint servers filtered by a keyword pattern. collect() returns
+(records, errors) and keeps nothing — the caller remembers what was reported, so a failed run
+loses nothing. Ported from F:\\neuropolitik\\tools\\fetch_papers.py (Neuropolitik session)."""
+import json
+import re
+import time
+import xml.etree.ElementTree as ET
+from datetime import date, timedelta
+
+import requests
+
+UA = 'diora-radar/1.0 (personal research digest; https://diora.creamfresh.xyz)'
+ABSTRACT_CHARS = 1200
+RXIV_SERVERS = ('biorxiv', 'medrxiv')
+
+
+def get(url, params=None, retries=3, timeout=60):
+ for attempt in range(retries):
+ try:
+ r = requests.get(url, params=params, headers={'User-Agent': UA}, timeout=timeout)
+ r.raise_for_status()
+ return r.text
+ except requests.RequestException:
+ if attempt == retries - 1:
+ raise
+ time.sleep(3 * (attempt + 1))
+
+
+def clip(text):
+ text = re.sub(r'\s+', ' ', text or '').strip()
+ return text if len(text) <= ABSTRACT_CHARS else text[:ABSTRACT_CHARS].rsplit(' ', 1)[0] + ' …'
+
+
+def fetch_pubmed(queries, days):
+ base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/'
+ records = []
+ for topic, query in queries.items():
+ ids = json.loads(get(base + 'esearch.fcgi', {'db': 'pubmed', 'term': query, 'reldate': days,
+ 'datetype': 'edat', 'retmax': 300, 'retmode': 'json'}))
+ ids = ids['esearchresult']['idlist']
+ for i in range(0, len(ids), 100):
+ root = ET.fromstring(get(base + 'efetch.fcgi', {'db': 'pubmed', 'id': ','.join(ids[i:i + 100]),
+ 'retmode': 'xml'}))
+ for art in root.iter('PubmedArticle'):
+ pmid = art.findtext('.//PMID')
+ t = art.find('.//ArticleTitle')
+ abstract = ' '.join(''.join(a.itertext()) for a in art.findall('.//Abstract/AbstractText'))
+ doi = next((e.text for e in art.findall('.//ArticleId') if e.get('IdType') == 'doi'), None)
+ authors = art.findall('.//Author')
+ records.append({
+ 'id': f'pmid:{pmid}', 'doi': doi, 'source': 'PubMed', 'topic': topic,
+ 'title': clip(''.join(t.itertext()) if t is not None else ''),
+ 'journal': art.findtext('.//Journal/Title'),
+ 'first_author': authors[0].findtext('LastName') if authors else None,
+ 'n_authors': len(authors), 'pub_types': [p.text for p in art.findall('.//PublicationType')],
+ 'date': art.findtext('.//PubDate/Year'), 'url': f'https://pubmed.ncbi.nlm.nih.gov/{pmid}/',
+ 'abstract': clip(abstract),
+ })
+ time.sleep(0.4) # NCBI: 3 requests a second without a key
+ return records
+
+
+def fetch_arxiv(query, days):
+ cutoff = (date.today() - timedelta(days=days)).isoformat()
+ xml = get('https://export.arxiv.org/api/query', {'search_query': query, 'sortBy': 'submittedDate',
+ 'sortOrder': 'descending', 'max_results': 150})
+ ns = {'a': 'http://www.w3.org/2005/Atom', 'arxiv': 'http://arxiv.org/schemas/atom'}
+ records = []
+ for e in ET.fromstring(xml).findall('a:entry', ns):
+ published = e.findtext('a:published', '', ns)[:10]
+ if published < cutoff:
+ continue
+ arxiv_id = e.findtext('a:id', '', ns).rsplit('/abs/', 1)[-1]
+ cat = e.find('arxiv:primary_category', ns)
+ authors = e.findall('a:author', ns)
+ records.append({
+ 'id': 'arxiv:' + re.sub(r'v\d+$', '', arxiv_id), 'doi': None, 'source': 'arXiv', 'topic': None,
+ 'title': clip(e.findtext('a:title', '', ns)),
+ 'journal': f"arXiv {cat.get('term') if cat is not None else ''}".strip(),
+ 'first_author': authors[0].findtext('a:name', '', ns) if authors else None,
+ 'n_authors': len(authors), 'pub_types': ['Preprint'], 'date': published,
+ 'url': f'https://arxiv.org/abs/{arxiv_id}', 'abstract': clip(e.findtext('a:summary', '', ns)),
+ })
+ return records
+
+
+def fetch_rxiv(server, days, keywords):
+ start = (date.today() - timedelta(days=days)).isoformat()
+ end = date.today().isoformat()
+ records, cursor, total = [], 0, None
+ while total is None or cursor < total:
+ data = json.loads(get(f'https://api.biorxiv.org/details/{server}/{start}/{end}/{cursor}'))
+ msg = data['messages'][0]
+ if msg.get('status') != 'ok':
+ break
+ total = int(msg.get('total', 0))
+ batch = data.get('collection', [])
+ if not batch:
+ break
+ for p in batch:
+ if not keywords.search(f"{p.get('title', '')} {p.get('abstract', '')}"):
+ continue
+ authors = (p.get('authors') or '').split(';')
+ records.append({
+ 'id': 'doi:' + p['doi'], 'doi': p['doi'], 'source': server, 'topic': None,
+ 'title': clip(p.get('title')), 'journal': f"{server} ({p.get('category')})",
+ 'first_author': authors[0], 'n_authors': len(authors), 'pub_types': ['Preprint'],
+ 'date': p.get('date'), 'url': f"https://www.{server}.org/content/{p['doi']}v{p.get('version', 1)}",
+ 'abstract': clip(p.get('abstract')), 'institution': p.get('author_corresponding_institution'),
+ })
+ cursor += len(batch)
+ time.sleep(0.3)
+ return records
+
+
+def collect(config, days, seen=frozenset()):
+ """Every source of the config; doubles (same id, DOI or title) and what was reported
+ before (seen) left out. (records, errors)."""
+ keywords = re.compile(config.get('keywords') or r'(?!)', re.I)
+ jobs = []
+ if config.get('pubmed'):
+ jobs.append(('PubMed', lambda: fetch_pubmed(config['pubmed'], days)))
+ if config.get('arxiv'):
+ jobs.append(('arXiv', lambda: fetch_arxiv(config['arxiv'], days)))
+ for server in config.get('rxiv', RXIV_SERVERS) if config.get('keywords') else ():
+ jobs.append((server, lambda s=server: fetch_rxiv(s, days, keywords)))
+ records, errors = [], []
+ for name, fn in jobs:
+ try:
+ records += fn()
+ except Exception as e: # noqa: BLE001 - a source down mustn't stop the others
+ errors.append(f'{name}: {e}')
+ unique, keys = [], set()
+ for r in records:
+ k = {r['id'], (r['doi'] or '').lower(), re.sub(r'\W', '', r['title'].lower())} - {''}
+ if keys & k or r['id'] in seen or (r['doi'] and f"doi:{r['doi']}" in seen):
+ continue
+ keys |= k
+ unique.append(r)
+ return unique, errors
diff --git a/projects/radar.py b/projects/radar.py
new file mode 100644
index 0000000..5b1ebbc
--- /dev/null
+++ b/projects/radar.py
@@ -0,0 +1,379 @@
+"""The Radar of a project (docs/projects.md): once a week the new papers (projects/papers.py)
+go to Claude in a batch (half price, done within the hour as a rule): one request sorts every
+paper into the config's groups and writes about the worth-reading ones (JSON schema), one looks
+for the week's news (web search). The answer becomes a RadarDigest — the bundle format of
+F:\\neuropolitik\\tools\\digest_batch.py (version 1), so a digest made elsewhere can come in too.
+
+Runs from the host's crontab: `manage.py radar` every half hour submits what is due and
+collects what is done. Needs ANTHROPIC_API_KEY in the container's environment.
+"""
+import json
+import os
+from datetime import date, timedelta
+from zoneinfo import ZoneInfo
+
+from django.db import transaction
+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 . import papers
+from .models import Project, Radar, RadarDigest
+
+DEFAULT_MODEL = 'claude-opus-5-5'
+# Batch prices: half of Opus 5.5's list prices ($4 / $20 per million tokens); web search $10 / 1000.
+PRICE_IN, PRICE_OUT, PRICE_SEARCH = 2.00 / 1e6, 10.00 / 1e6, 10.00 / 1000
+SEEN_MAX = 20000
+# The weekday and hour of a config are the user's (the server runs on UTC).
+ZONE = ZoneInfo('Europe/Berlin')
+DIGESTS_SHOWN = 12
+
+
+def papers_schema(groups):
+ return {
+ 'type': 'object',
+ 'properties': {
+ 'auf_einen_blick': {'type': 'string'},
+ 'items': {'type': 'array', 'items': {
+ 'type': 'object',
+ 'properties': {
+ 'id': {'type': 'string'},
+ 'gruppe': {'type': 'string', 'enum': list(groups)},
+ 'worum_es_geht': {'type': 'string'},
+ 'einordnung': {'type': 'string'},
+ 'politischer_haken': {'type': 'string'},
+ 'kurz': {'type': 'string'},
+ },
+ 'required': ['id', 'gruppe', 'worum_es_geht', 'einordnung', 'politischer_haken', 'kurz'],
+ 'additionalProperties': False,
+ }},
+ 'grundlagenbegriff': {
+ 'type': 'object',
+ 'properties': {'begriff': {'type': 'string'}, 'erklaerung': {'type': 'string'}},
+ 'required': ['begriff', 'erklaerung'],
+ 'additionalProperties': False,
+ },
+ },
+ 'required': ['auf_einen_blick', 'items', 'grundlagenbegriff'],
+ 'additionalProperties': False,
+ }
+
+
+def _model_record(r):
+ """What the model needs of a paper to sort it."""
+ return {k: r.get(k) for k in ('id', 'source', 'topic', 'journal', 'first_author', 'n_authors', 'pub_types',
+ 'date', 'title', 'abstract', 'institution') if r.get(k) not in (None, '', [])}
+
+
+def _terms(radar):
+ """The basic terms explained before: not again."""
+ out = []
+ for b in radar.digests.values_list('bundle', flat=True)[:52]:
+ t = ((b or {}).get('grundlagenbegriff') or {}).get('begriff')
+ if t:
+ out.append(t)
+ return out
+
+
+def build_requests(config, records, terms, today):
+ model = config.get('model') or DEFAULT_MODEL
+ groups = config.get('groups') or ['lesenswert', 'rauschen']
+ prompt = config['papers_task'].format(
+ n=len(records), glossar=', '.join(terms) if terms else '(noch keine)',
+ records=json.dumps([_model_record(r) for r in records], ensure_ascii=False))
+ reqs = [{'custom_id': 'papers', 'params': {
+ 'model': model, 'max_tokens': 48000, 'thinking': {'type': 'adaptive'},
+ 'output_config': {'effort': 'high', 'format': {'type': 'json_schema', 'schema': papers_schema(groups)}},
+ 'system': config['system'], 'messages': [{'role': 'user', 'content': prompt}],
+ }}]
+ if config.get('news_task'):
+ reqs.append({'custom_id': 'news', 'params': {
+ 'model': model, 'max_tokens': 16000, 'thinking': {'type': 'adaptive'},
+ 'output_config': {'effort': 'medium'}, 'system': config['system'],
+ 'tools': [{'type': 'web_search_20260209', 'name': 'web_search', 'max_uses': 4}],
+ 'messages': [{'role': 'user', 'content': config['news_task'].format(today=today)}],
+ }})
+ return reqs
+
+
+def build_bundle(config, pending, papers_json, news_md, usage, batch_id, project_name=''):
+ groups = config.get('groups') or ['lesenswert', 'rauschen']
+ records = {r['id']: r for r in pending['records']}
+ items, seen = [], set()
+ for it in papers_json.get('items') or []:
+ r = records.get(it.get('id'))
+ if not r or it['id'] in seen or it.get('gruppe') not in groups:
+ continue # an id or group the model made up, or twice: left out
+ seen.add(it['id'])
+ items.append({**r, **it})
+ # What the model forgot shows, unsorted, among the rest.
+ fallback = 'technik' if 'technik' in groups else groups[-1]
+ for rid, r in records.items():
+ if rid not in seen:
+ items.append({**r, 'gruppe': fallback, 'worum_es_geht': '', 'einordnung': '', 'politischer_haken': '',
+ 'kurz': '(nicht eingeordnet)'})
+ per_source = {}
+ for r in pending['records']:
+ per_source[r['source']] = per_source.get(r['source'], 0) + 1
+ cost = (usage['input_tokens'] * PRICE_IN + usage['output_tokens'] * PRICE_OUT
+ + usage['web_search_requests'] * PRICE_SEARCH)
+ return {
+ 'version': 1, 'project': project_name, 'generated': pending['generated'], 'days': pending['days'],
+ 'model': config.get('model') or DEFAULT_MODEL, 'batch_id': batch_id,
+ 'auf_einen_blick': papers_json.get('auf_einen_blick', ''),
+ 'grundlagenbegriff': papers_json.get('grundlagenbegriff') or {'begriff': '', 'erklaerung': ''},
+ 'items': items, 'news_markdown': news_md,
+ 'stats': {'records': len(records), 'per_source': per_source, 'errors': pending.get('errors', []),
+ 'usage': usage, 'cost_usd': round(cost, 4)},
+ }
+
+
+def _client():
+ import anthropic # only where the Radar runs
+ return anthropic.Anthropic()
+
+
+# ---- running ---------------------------------------------------------------------------------
+
+def due(radar, now=None):
+ """Its weekday and hour have come (local time), and this week has no digest yet."""
+ if not radar.enabled or radar.pending or not radar.config.get('papers_task'):
+ return False
+ now = (now or timezone.now()).astimezone(ZONE)
+ c = radar.config
+ weekday, hour = int(c.get('weekday', 6)), int(c.get('hour', 18))
+ start = (now - timedelta(days=(now.weekday() - weekday) % 7)).replace(hour=hour, minute=0, second=0,
+ microsecond=0)
+ if start > now:
+ start -= timedelta(days=7)
+ last = radar.last_run_at.astimezone(ZONE) if radar.last_run_at else None
+ return last is None or last < start
+
+
+def submit(radar, force=False):
+ """Fetch the new papers and send the batch. Returns what happened, in words."""
+ if radar.pending and not force:
+ return 'Es läuft schon ein Abruf.'
+ c = radar.config
+ days = int(c.get('days', 7))
+ records, errors = papers.collect(c, days, set(radar.seen))
+ radar.last_run_at = timezone.now()
+ today = date.today().isoformat()
+ if not records:
+ radar.last_error = '; '.join(errors)
+ radar.pending = None
+ radar.save(update_fields=['last_run_at', 'last_error', 'pending'])
+ return 'Keine neuen Papers.' + (' Fehler: ' + radar.last_error if errors else '')
+ batch = _client().messages.batches.create(requests=build_requests(c, records, _terms(radar), today))
+ radar.pending = {'batch_id': batch.id, 'generated': today, 'days': days, 'records': records,
+ 'errors': errors, 'submitted': timezone.now().isoformat(timespec='seconds')}
+ radar.last_error = ''
+ radar.save(update_fields=['last_run_at', 'last_error', 'pending'])
+ return f'{len(records)} Papers an Claude geschickt ({batch.id}).'
+
+
+def _text(message):
+ return ''.join(b.text for b in message.content if b.type == 'text')
+
+
+def _add_usage(total, message):
+ u = message.usage
+ total['input_tokens'] += (u.input_tokens or 0) + (getattr(u, 'cache_read_input_tokens', 0) or 0) \
+ + (getattr(u, 'cache_creation_input_tokens', 0) or 0)
+ total['output_tokens'] += u.output_tokens or 0
+ stu = getattr(u, 'server_tool_use', None)
+ if stu:
+ total['web_search_requests'] += getattr(stu, 'web_search_requests', 0) or 0
+
+
+def collect(radar):
+ """The batch's answer, if it's there: a digest. Returns 'done', 'waiting' or an error text."""
+ p = radar.pending
+ if not p:
+ return 'done'
+ client = _client()
+ batch = client.messages.batches.retrieve(p['batch_id'])
+ if batch.processing_status != 'ended':
+ return 'waiting'
+ usage = {'input_tokens': 0, 'output_tokens': 0, 'web_search_requests': 0}
+ papers_json, news_md, problems = None, '', []
+ for res in client.messages.batches.results(batch.id):
+ if res.result.type != 'succeeded':
+ detail = getattr(getattr(res.result, 'error', None), 'type', '')
+ problems.append(f'{res.custom_id}: {res.result.type} {detail}'.strip())
+ continue
+ msg = res.result.message
+ _add_usage(usage, msg)
+ if msg.stop_reason == 'refusal':
+ problems.append(f'{res.custom_id}: abgelehnt')
+ elif res.custom_id == 'papers':
+ if msg.stop_reason == 'max_tokens':
+ problems.append('papers: max_tokens erreicht, Ausgabe unvollständig')
+ else:
+ try:
+ papers_json = json.loads(_text(msg))
+ except ValueError:
+ problems.append('papers: keine gültige JSON-Antwort')
+ elif res.custom_id == 'news':
+ news_md = _text(msg)
+ if papers_json is None:
+ # Nothing remembered as seen: the next run gets the papers again.
+ radar.pending = None
+ radar.last_error = 'Einordnung fehlgeschlagen: ' + '; '.join(problems)
+ radar.save(update_fields=['pending', 'last_error'])
+ return radar.last_error
+ p['errors'] = list(p.get('errors') or []) + problems
+ bundle = build_bundle(radar.config, p, papers_json, news_md, usage, batch.id)
+ store(radar, bundle, mark_seen=[r['id'] for r in p['records']])
+ return 'done'
+
+
+def store(radar, bundle, mark_seen=None):
+ """A digest in; what it reports counts as seen. Replaces one with the same batch."""
+ with transaction.atomic():
+ if bundle.get('batch_id'):
+ radar.digests.filter(batch_id=bundle['batch_id']).delete()
+ d = RadarDigest.objects.create(radar=radar, generated=date.fromisoformat(str(bundle['generated'])[:10]),
+ batch_id=bundle.get('batch_id') or '', bundle=bundle)
+ seen = list(dict.fromkeys(list(radar.seen) + list(mark_seen or [i.get('id') for i in bundle.get('items', [])])))
+ radar.seen = [s for s in seen if s][-SEEN_MAX:]
+ radar.pending = None
+ radar.last_error = ''
+ radar.save(update_fields=['seen', 'pending', 'last_error'])
+ return d
+
+
+def tick(now=None):
+ """What the cron job does: collect what's done, submit what's due. Returns lines to log."""
+ out = []
+ for radar in Radar.objects.filter(enabled=True).select_related('project'):
+ try:
+ if radar.pending:
+ r = collect(radar)
+ if r != 'waiting':
+ out.append(f'radar {radar.project_id}: {r}')
+ elif due(radar, now):
+ out.append(f'radar {radar.project_id}: {submit(radar)}')
+ except Exception as e: # noqa: BLE001 - one radar's trouble mustn't stop the others
+ radar.last_error = str(e)[:2000]
+ radar.save(update_fields=['last_error'])
+ out.append(f'radar {radar.project_id}: Fehler {e}')
+ return out
+
+
+# ---- API ---------------------------------------------------------------------------------------
+
+def _auth(request):
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ return None
+
+
+def next_run(radar, now=None):
+ """When the cron job will fetch next: now (it's due), else the coming weekday and hour."""
+ if not radar.enabled:
+ return None
+ now = (now or timezone.now()).astimezone(ZONE)
+ if radar.pending:
+ return None
+ if due(radar, now):
+ return now.isoformat()
+ c = radar.config
+ weekday, hour = int(c.get('weekday', 6)), int(c.get('hour', 18))
+ t = (now + timedelta(days=(weekday - now.weekday()) % 7)).replace(hour=hour, minute=0, second=0, microsecond=0)
+ if t <= now:
+ t += timedelta(days=7)
+ return t.isoformat()
+
+
+def _radar_json(radar):
+ c = radar.config
+ return {'enabled': radar.enabled, 'title': c.get('title', 'Radar'), 'groups': c.get('groups', []),
+ 'group_labels': c.get('group_labels', {}), 'weekday': c.get('weekday', 6), 'hour': c.get('hour', 18),
+ 'days': c.get('days', 7), 'sources': [s for s, on in (('PubMed', c.get('pubmed')), ('arXiv', c.get('arxiv')),
+ ('bioRxiv/medRxiv', c.get('keywords'))) if on],
+ 'running': bool(radar.pending), 'submitted': (radar.pending or {}).get('submitted'),
+ 'last_run_at': radar.last_run_at.isoformat() if radar.last_run_at else None,
+ 'last_error': radar.last_error, 'next_run': next_run(radar),
+ 'key': bool(os.environ.get('ANTHROPIC_API_KEY'))}
+
+
+def _digest_json(d):
+ return {'id': d.id, 'generated': d.generated.isoformat(), 'bundle': d.bundle}
+
+
+@require_http_methods(['GET'])
+def radar_view(request, pk):
+ """The project's Radar and its latest digests (or radar: null)."""
+ err = _auth(request)
+ if err:
+ return err
+ p = Project.objects.filter(pk=pk, user=request.user).first()
+ if not p:
+ return JsonResponse({'error': 'not found'}, status=404)
+ radar = Radar.objects.filter(project=p).first()
+ if not radar:
+ return JsonResponse({'radar': None, 'digests': []})
+ return JsonResponse({'radar': _radar_json(radar),
+ 'digests': [_digest_json(d) for d in radar.digests.all()[:DIGESTS_SHOWN]]})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def radar_settings(request, pk):
+ """{enabled}."""
+ err = _auth(request)
+ if err:
+ return err
+ radar = Radar.objects.filter(project__pk=pk, project__user=request.user).first()
+ if not radar:
+ return JsonResponse({'error': 'not found'}, status=404)
+ try:
+ body = json.loads(request.body or b'{}')
+ except ValueError:
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ if 'enabled' in body:
+ radar.enabled = bool(body['enabled'])
+ radar.save(update_fields=['enabled'])
+ return JsonResponse({'radar': _radar_json(radar)})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def radar_run(request, pk):
+ """Fetch now rather than on the weekday: the cron job takes it within half an hour."""
+ err = _auth(request)
+ if err:
+ return err
+ radar = Radar.objects.filter(project__pk=pk, project__user=request.user).first()
+ if not radar:
+ return JsonResponse({'error': 'not found'}, status=404)
+ if not radar.pending:
+ radar.last_run_at = None
+ radar.enabled = True
+ radar.save(update_fields=['last_run_at', 'enabled'])
+ return JsonResponse({'radar': _radar_json(radar)})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def radar_import(request, pk):
+ """A digest made elsewhere (digest_batch.py --diora-url, Bearer token): the bundle."""
+ err = _auth(request)
+ if err:
+ return err
+ radar = Radar.objects.filter(project__pk=pk, project__user=request.user).first()
+ if not radar:
+ return JsonResponse({'error': 'not found'}, status=404)
+ try:
+ bundle = json.loads(request.body or b'{}')
+ date.fromisoformat(str(bundle['generated'])[:10])
+ if bundle.get('version') != 1 or not isinstance(bundle.get('items'), list):
+ raise ValueError
+ except (ValueError, KeyError, TypeError):
+ return JsonResponse({'error': 'kein Digest (version 1)'}, status=400)
+ if len(request.body) > 5_000_000:
+ return JsonResponse({'error': 'zu groß'}, status=400)
+ d = store(radar, bundle)
+ return JsonResponse({'digest': d.id})
diff --git a/projects/radar_presets.py b/projects/radar_presets.py
new file mode 100644
index 0000000..263d348
--- /dev/null
+++ b/projects/radar_presets.py
@@ -0,0 +1,120 @@
+"""Radar configurations (docs/projects.md): what a project's Radar searches and how Claude
+sorts it. NEUROPOLITIK is taken as it was from F:\neuropolitik\tools (fetch_papers.py,
+digest_batch.py, the "Neuropolitik Grundlagen" session); generated, edit with care: papers_task
+keeps {n}, {glossar} and {records}, news_task {today}."""
+
+# flake8: noqa: E501
+NEUROPOLITIK = {'title': 'Neuropolitik-Digest',
+ 'model': 'claude-opus-5-5',
+ 'days': 7,
+ 'weekday': 6,
+ 'hour': 18,
+ 'pubmed': {'technik': '"brain-computer interface*"[tiab] OR "brain computer interface*"[tiab] OR '
+ '"brain-machine interface*"[tiab] OR "neural interface*"[tiab] OR neuroprosthe*[tiab] '
+ 'OR "speech decoding"[tiab] OR "Brain-Computer Interfaces"[Mesh]',
+ 'gesellschaft': 'neuroethic*[tiab] OR neuroright*[tiab] OR "neural data"[tiab] OR "mental '
+ 'privacy"[tiab] OR "cognitive liberty"[tiab] OR "neurotechnolog*"[tiab] OR '
+ '"neural privacy"[tiab]',
+ 'propriozeption': '(propriocept*[tiab] OR "sensory feedback"[tiab] OR "somatosensory '
+ 'feedback"[tiab] OR "artificial touch"[tiab] OR "intracortical '
+ 'microstimulation"[tiab] OR embodiment[tiab] OR "body schema"[tiab]) AND '
+ '(prosthe*[tiab] OR neuroprosthe*[tiab] OR bionic[tiab] OR "neural '
+ 'interface*"[tiab] OR "brain-computer"[tiab] OR "brain-machine"[tiab] OR '
+ '"nerve stimulation"[tiab] OR "spinal cord stimulation"[tiab] OR '
+ 'exoskeleton*[tiab])'},
+ 'arxiv': 'abs:"brain-computer interface" OR abs:"brain-machine interface" OR abs:"brain computer interface" '
+ 'OR abs:"neural interface" OR abs:neurotechnology OR abs:"neural decoding" OR abs:neuroprosthesis '
+ 'OR (abs:proprioception AND (abs:prosthesis OR abs:prosthetic OR abs:"neural interface")) OR '
+ 'abs:"intracortical microstimulation"',
+ 'keywords': 'brain[- ]?(computer|machine) interface|\\bBCIs?\\b|neuroprosthe|neural interface|intracortical '
+ '(decod|record|microstim|array|implant)|neural decod|speech decod|brain '
+ 'decod|neurotechnolog|neuroethic|neuroright|neural data|mental privacy|cognitive '
+ 'liberty|closed[- ]loop (neuro)?(stimulation|modulation)|adaptive deep brain '
+ 'stimulation|brain[- ]to[- ](text|speech|image)|intracortical microstimulation|artificial '
+ '(touch|sensation|proprioception)|propriocept\\w* '
+ '(feedback|restoration|neuroprosthe)|bidirectional (BCI|brain|neural)|bionic '
+ '(limb|leg|arm|hand)|myoneural|somatosensory (feedback|neuroprosthe)|(sensory|somatosensory) '
+ 'feedback[^.]{0,60}prosthe',
+ 'rxiv': ['biorxiv', 'medrxiv'],
+ 'groups': ['lesenswert', 'propriozeption', 'gesellschaft', 'technik', 'rauschen'],
+ 'group_labels': {'lesenswert': 'Lesenswert',
+ 'propriozeption': 'Propriozeption und Körper',
+ 'gesellschaft': 'Gesellschaft, Ethik, Recht',
+ 'technik': 'Technik im Überblick',
+ 'rauschen': 'Rauschen'},
+ 'system': 'Du schreibst den wöchentlichen Digest für „Neuropolitik“, ein geplantes Publikationsprojekt '
+ '(Blog/Podcast) über Brain-Computer-Interfaces (BCIs), Neurotechnologie, Propriozeption und '
+ 'Neuroprothetik und ihre Auswirkungen auf die Gesellschaft – aus einer linken Perspektive '
+ '(Marxismus, Ljubljana-Schule: Žižek, Zupančič, Dolar; dazu Malabou, Johnston, Pasquinelli, '
+ 'Rose/Abi-Rached).\n'
+ '\n'
+ 'Die Person hinter dem Projekt ist theoretisch geschult, hat aber keine neurowissenschaftliche '
+ 'Ausbildung und arbeitet sich gerade in die Grundlagen ein. Theorie musst du nicht erklären, '
+ 'Technik schon – knapp und präzise.\n'
+ '\n'
+ 'Ein eigener Schwerpunkt ist Propriozeption: künstliche Rückmeldung von Körperlage und Berührung '
+ '(intrakortikale Mikrostimulation, bidirektionale BCIs, Nervenschnittstellen in Prothesen, '
+ 'myoneurale Schnittstellen), Embodiment und Körperschema bei Prothesen und Exoskeletten, Verlust '
+ 'und Wiederherstellung der Eigenwahrnehmung.\n'
+ '\n'
+ 'Regeln:\n'
+ '- Schreib auf Deutsch, sachlich und präzise. Die linke Perspektive gehört nur in den '
+ '„politischen Haken“.\n'
+ '- Behaupte nichts, was nicht in der Quelle steht. Du kennst von den Papers nur Titel und '
+ '(gekürztes) Abstract – schreib „laut Abstract“, wo das wichtig ist. Erfinde keine Zahlen, '
+ 'Autor:innen oder Ergebnisse.\n'
+ '- Gib Abstracts nicht wörtlich wieder, sondern fasse in eigenen Worten zusammen.',
+ 'papers_task': 'Ordne jedes der folgenden {n} Papers genau einer Gruppe zu und schreib die passenden Texte. '
+ 'Jede ID muss genau einmal in `items` vorkommen.\n'
+ '\n'
+ 'Gruppen:\n'
+ '- lesenswert (höchstens 6, möglichst 3–6): Studien an Menschen und klinische BCI-Studien; '
+ 'alles zu Propriozeption und sensorischer Rückmeldung; Decoding von Sprache, innerer '
+ 'Sprache, Bildern oder Bedeutung (besonders nicht-invasiv: EEG, fMRT, MEG, fNIRS); '
+ '„schreibende“ Neurotechnik (Stimulation, Closed-Loop, Tiefe Hirnstimulation mit BCI-Bezug); '
+ 'Durchbrüche bei Implantaten; Papers kommerzieller Akteure (Neuralink, Synchron, '
+ 'Paradromics, Precision Neuroscience, Blackrock Neurotech, Merge Labs, Meta, Apple, '
+ 'chinesische BCI-Firmen); Einsätze in Militär, Arbeitswelt oder Konsumprodukten; neuronale '
+ 'Daten; große Reviews. Was politisch oder gesellschaftlich Gewicht hat, geht vor rein '
+ 'inkrementeller Technik.\n'
+ '- propriozeption: Papers mit Propriozeptions-Bezug, die nicht lesenswert sind.\n'
+ '- gesellschaft: Ethik, Recht, Regulierung, Neurorechte, Datenschutz, Disability Studies, '
+ 'Wissenschaftsforschung (STS), Philosophie.\n'
+ '- technik: übrige relevante BCI- und Neuroprothetik-Technik.\n'
+ '- rauschen: kein echter BCI- oder Neurotech-Bezug (Schlagwort-Fehltreffer) oder sehr '
+ 'inkrementelle ML-Arbeiten zur EEG-Klassifikation.\n'
+ '\n'
+ 'Felder je Paper:\n'
+ '- lesenswert: `worum_es_geht` (2–3 Sätze), `einordnung` (Studientyp: Peer Review oder '
+ 'Preprint, Mensch/Tier/Simulation, Zahl der Teilnehmenden falls genannt; invasiv oder '
+ 'nicht-invasiv; lesend oder schreibend; Hype-Check: was zeigt das Paper wirklich, was '
+ 'nicht), `politischer_haken` (1–2 Sätze als Anregung, nicht als fertige These: Eigentum und '
+ 'Plattformabhängigkeit, Arbeit und Überwachung, neuronale Daten als Ware, Militär, '
+ 'Behinderung und Legitimationsnarrative, Ideologie des „Gehirnsubjekts“, Gedankenlesen und '
+ 'Subjektivität, Körperschema und Eigenleib …) und `kurz` (ein Satz).\n'
+ '- propriozeption, gesellschaft: `kurz` (1–2 Sätze), die übrigen Felder leer.\n'
+ '- technik: `kurz` (eine Zeile: was daran neu ist), die übrigen Felder leer.\n'
+ '- rauschen: alle Textfelder leer.\n'
+ '\n'
+ 'Außerdem:\n'
+ '- `auf_einen_blick`: 3–5 Sätze – was war diese Woche wichtig, gibt es einen Trend?\n'
+ '- `grundlagenbegriff`: Erkläre in 3–5 Sätzen einen technischen Begriff, der in den Papers '
+ 'dieser Woche vorkommt und für Leser:innen mit Theorie-, aber ohne Neuro-Hintergrund wichtig '
+ 'ist. Nicht wiederholen: {glossar}.\n'
+ '\n'
+ 'Bei einer ruhigen Woche fällt alles kürzer aus. Nichts künstlich aufblähen.\n'
+ '\n'
+ 'Papers (JSON):\n'
+ '{records}',
+ 'news_task': 'Recherchiere mit höchstens 4 Websuchen Nachrichten der letzten 7 Tage (Stichtag {today}) zu '
+ 'Neurotechnologie und BCIs: Neuralink, Synchron und andere BCI-Firmen samt Finanzierung, '
+ 'Gesetze zu neuronalen Daten und Neurorechten (USA, EU, Chile, UNESCO), Chinas '
+ 'BCI-Industriepolitik, DARPA und Militär.\n'
+ '\n'
+ 'Antworte nur mit einer Markdown-Liste, ohne Einleitung, ein Eintrag pro Meldung:\n'
+ '- **Kurztitel:** 1–2 Sätze. [Quelle](URL)\n'
+ '\n'
+ 'Nimm nur Meldungen auf, deren Quelle du in den Suchergebnissen gesehen hast, und nur aus den '
+ 'letzten 7 Tagen. Gibt es nichts Relevantes, antworte genau: Keine relevanten Meldungen.'}
+
+PRESETS = {'neuropolitik': NEUROPOLITIK}
diff --git a/projects/test_radar.py b/projects/test_radar.py
new file mode 100644
index 0000000..4c40376
--- /dev/null
+++ b/projects/test_radar.py
@@ -0,0 +1,144 @@
+"""The Radar (projects/radar.py, projects/papers.py) with a pretend Claude."""
+import json
+from datetime import datetime
+from types import SimpleNamespace as NS
+from unittest.mock import patch
+from zoneinfo import ZoneInfo
+
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+from . import papers, radar
+from .models import Project, Radar
+from .radar_presets import NEUROPOLITIK
+
+BERLIN = ZoneInfo('Europe/Berlin')
+REC = [
+ {'id': 'pmid:1', 'doi': '10.1/a', 'source': 'PubMed', 'topic': 'technik', 'title': 'Speech BCI',
+ 'journal': 'Nature', 'first_author': 'Willett', 'n_authors': 9, 'pub_types': [], 'date': '2026',
+ 'url': 'https://pubmed.ncbi.nlm.nih.gov/1/', 'abstract': 'A speech neuroprosthesis.'},
+ {'id': 'arxiv:2610.1', 'doi': None, 'source': 'arXiv', 'topic': None, 'title': 'EEG noise',
+ 'journal': 'arXiv eess.SP', 'first_author': 'A', 'n_authors': 2, 'pub_types': ['Preprint'],
+ 'date': '2026-10-18', 'url': 'https://arxiv.org/abs/2610.1', 'abstract': 'EEG.'},
+]
+
+
+def message(text, stop='end_turn', searches=0):
+ return NS(content=[NS(type='text', text=text)], stop_reason=stop,
+ usage=NS(input_tokens=1000, output_tokens=200, cache_read_input_tokens=0, cache_creation_input_tokens=0,
+ server_tool_use=NS(web_search_requests=searches) if searches else None))
+
+
+class FakeClient:
+ def __init__(self, status='ended', papers_text=None):
+ self.created = []
+ self.status = status
+ self.papers_text = papers_text or json.dumps({
+ 'auf_einen_blick': 'Eine ruhige Woche.',
+ 'grundlagenbegriff': {'begriff': 'Decoder', 'erklaerung': 'Ein Modell, das …'},
+ 'items': [{'id': 'pmid:1', 'gruppe': 'lesenswert', 'worum_es_geht': 'Sprache.', 'einordnung': 'Peer Review.',
+ 'politischer_haken': 'Stimme.', 'kurz': 'Sprache aus dem Kortex.'},
+ {'id': 'erfunden', 'gruppe': 'technik', 'worum_es_geht': '', 'einordnung': '',
+ 'politischer_haken': '', 'kurz': 'x'}]})
+ outer = self
+
+ class Batches:
+ def create(self, requests):
+ outer.created.append(requests)
+ return NS(id='msgbatch_1', processing_status='in_progress')
+
+ def retrieve(self, bid):
+ return NS(id=bid, processing_status=outer.status)
+
+ def results(self, bid):
+ return [NS(custom_id='papers', result=NS(type='succeeded', message=message(outer.papers_text))),
+ NS(custom_id='news', result=NS(type='succeeded', message=message('- **X:** y. [Q](https://q)', searches=2)))]
+ self.messages = NS(batches=Batches())
+
+
+class RadarTests(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username='radar', password='pw12345678')
+ self.client.force_login(self.user)
+ self.project = Project.objects.create(user=self.user, data_ct='x', data_iv='a1' * 12)
+ self.radar = Radar.objects.create(project=self.project, config=NEUROPOLITIK)
+
+ def test_due_on_its_weekday_once_a_week(self):
+ sat = datetime(2026, 10, 24, 20, 0, tzinfo=BERLIN)
+ sun_early = datetime(2026, 10, 25, 17, 0, tzinfo=BERLIN)
+ sun = datetime(2026, 10, 25, 18, 5, tzinfo=BERLIN)
+ self.radar.last_run_at = datetime(2026, 10, 18, 18, 1, tzinfo=BERLIN)
+ self.assertFalse(radar.due(self.radar, sat))
+ self.assertFalse(radar.due(self.radar, sun_early))
+ self.assertTrue(radar.due(self.radar, sun))
+ self.radar.last_run_at = sun
+ self.assertFalse(radar.due(self.radar, datetime(2026, 10, 25, 23, 0, tzinfo=BERLIN)))
+ self.radar.last_run_at = None
+ self.assertTrue(radar.due(self.radar, sat), 'never run: at once')
+
+ def test_submit_and_collect(self):
+ fake = FakeClient(status='in_progress')
+ with patch.object(radar, '_client', return_value=fake), \
+ patch.object(papers, 'collect', return_value=(list(REC), ['medrxiv: down'])):
+ self.assertIn('2 Papers', radar.submit(self.radar))
+ reqs = fake.created[0]
+ self.assertEqual([r['custom_id'] for r in reqs], ['papers', 'news'])
+ self.assertIn('Speech BCI', reqs[0]['params']['messages'][0]['content'])
+ self.assertEqual(radar.collect(self.radar), 'waiting')
+ fake.status = 'ended'
+ self.assertEqual(radar.collect(self.radar), 'done')
+ self.radar.refresh_from_db()
+ self.assertIsNone(self.radar.pending)
+ self.assertEqual(set(self.radar.seen), {'pmid:1', 'arxiv:2610.1'})
+ b = self.radar.digests.get().bundle
+ self.assertEqual({i['id']: i['gruppe'] for i in b['items']}, {'pmid:1': 'lesenswert', 'arxiv:2610.1': 'technik'})
+ self.assertEqual(b['items'][0]['title'], 'Speech BCI', 'titles from the sources, not the model')
+ self.assertEqual(b['stats']['usage'], {'input_tokens': 2000, 'output_tokens': 400, 'web_search_requests': 2})
+ self.assertEqual(b['stats']['errors'], ['medrxiv: down'])
+ # The next week the basic term isn't asked for again.
+ self.assertIn('Decoder', radar.build_requests(NEUROPOLITIK, REC, radar._terms(self.radar), '2026-10-26')[0]
+ ['params']['messages'][0]['content'])
+
+ def test_a_failed_sorting_forgets_nothing(self):
+ fake = FakeClient(papers_text='kein json')
+ self.radar.pending = {'batch_id': 'b', 'generated': '2026-10-19', 'days': 7, 'records': REC, 'errors': []}
+ self.radar.save()
+ with patch.object(radar, '_client', return_value=fake):
+ self.assertIn('fehlgeschlagen', radar.collect(self.radar))
+ self.radar.refresh_from_db()
+ self.assertEqual((self.radar.seen, self.radar.pending, self.radar.digests.count()), ([], None, 0))
+
+ def test_nothing_new_no_batch(self):
+ fake = FakeClient()
+ with patch.object(radar, '_client', return_value=fake), patch.object(papers, 'collect', return_value=([], [])):
+ self.assertEqual(radar.submit(self.radar), 'Keine neuen Papers.')
+ self.assertEqual(fake.created, [])
+
+ def test_api_view_run_import(self):
+ r = self.client.get(f'/projects/{self.project.id}/radar/').json()
+ self.assertEqual((r['radar']['title'], r['digests']), ('Neuropolitik-Digest', []))
+ bundle = {'version': 1, 'generated': '2026-10-19', 'items': [{'id': 'pmid:9', 'gruppe': 'technik'}],
+ 'batch_id': 'msgbatch_x'}
+ self.client.post(f'/projects/{self.project.id}/radar/import/', json.dumps(bundle), content_type='application/json')
+ self.client.post(f'/projects/{self.project.id}/radar/import/', json.dumps(bundle), content_type='application/json')
+ r = self.client.get(f'/projects/{self.project.id}/radar/').json()
+ self.assertEqual(len(r['digests']), 1, 'the same batch once')
+ self.assertIn('pmid:9', Radar.objects.get().seen)
+ self.assertEqual(self.client.post(f'/projects/{self.project.id}/radar/import/', '{}',
+ content_type='application/json').status_code, 400)
+ r = self.client.post(f'/projects/{self.project.id}/radar/run/').json()
+ self.assertTrue(radar.due(Radar.objects.get()))
+ other = User.objects.create_user(username='x', password='pw12345678')
+ self.client.force_login(other)
+ self.assertEqual(self.client.get(f'/projects/{self.project.id}/radar/').status_code, 404)
+
+
+class PapersTests(TestCase):
+ def test_collect_leaves_out_doubles_and_seen(self):
+ dup = dict(REC[0], id='doi:10.1/a', source='biorxiv')
+ with patch.object(papers, 'fetch_pubmed', return_value=[REC[0]]), \
+ patch.object(papers, 'fetch_arxiv', return_value=[REC[1]]), \
+ patch.object(papers, 'fetch_rxiv', side_effect=[[dup], OSError('down')]):
+ got, errors = papers.collect(NEUROPOLITIK, 7, seen={'arxiv:2610.1'})
+ self.assertEqual([r['id'] for r in got], ['pmid:1'])
+ self.assertEqual(errors, ['medrxiv: down'])
diff --git a/projects/tests.py b/projects/tests.py
new file mode 100644
index 0000000..0f4a0e5
--- /dev/null
+++ b/projects/tests.py
@@ -0,0 +1,109 @@
+"""Projekte: the API (projects/views.py)."""
+import json
+from unittest.mock import patch
+
+from django.contrib.auth.models import User
+from django.core.cache import cache
+from django.test import TestCase
+
+from .models import Project, ProjectItem
+from .views import doi_of
+
+IV = 'a1' * 12
+
+
+class Base(TestCase):
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='forscher', password='pw12345678')
+ self.client.force_login(self.user)
+
+ def post(self, url, body=None):
+ return self.client.post(url, json.dumps(body or {}), content_type='application/json')
+
+ def make(self, ct='name'):
+ return self.post('/projects/create/', {'preset': 'research', 'data_ct': ct, 'data_iv': IV}).json()['project']
+
+
+class ProjectTests(Base):
+ def test_create_list_update(self):
+ p = self.make()
+ self.assertEqual(p['preset'], 'research')
+ r = self.client.get('/projects/').json()
+ self.assertEqual([x['id'] for x in r['projects']], [p['id']])
+ self.assertIn('research', r['presets'])
+ r = self.post(f"/projects/{p['id']}/", {'data_ct': 'neu', 'data_iv': 'b2' * 12, 'base_iv': IV})
+ self.assertEqual(r.json()['project']['data_ct'], 'neu')
+ # Written from an old state: refused with the current one.
+ r = self.post(f"/projects/{p['id']}/", {'data_ct': 'alt', 'data_iv': 'c3' * 12, 'base_iv': IV})
+ self.assertEqual((r.status_code, r.json()['project']['data_ct']), (409, 'neu'))
+
+ def test_bad_input(self):
+ self.assertEqual(self.post('/projects/create/', {'data_ct': 'x', 'data_iv': 'zz'}).status_code, 400)
+ self.assertEqual(self.post('/projects/create/', {'preset': 'nope', 'data_ct': 'x', 'data_iv': IV}).status_code, 400)
+
+ def test_only_ones_own(self):
+ p = self.make()
+ other = User.objects.create_user(username='andere', password='pw12345678')
+ self.client.force_login(other)
+ self.assertEqual(self.client.get(f"/projects/{p['id']}/").status_code, 404)
+ self.assertEqual(self.post(f"/projects/{p['id']}/items/add/", {'kind': 'note', 'data_ct': 'x', 'data_iv': IV}).status_code, 404)
+ self.assertEqual(self.client.get('/projects/').json()['projects'], [])
+ self.client.logout()
+ self.assertEqual(self.client.get('/projects/').status_code, 401)
+
+ def test_delete_takes_the_items(self):
+ p = self.make()
+ self.post(f"/projects/{p['id']}/items/add/", {'kind': 'note', 'data_ct': 'x', 'data_iv': IV})
+ self.post(f"/projects/{p['id']}/delete/")
+ self.assertEqual((Project.objects.count(), ProjectItem.objects.count()), (0, 0))
+
+
+class ItemTests(Base):
+ def test_links_once_notes_many(self):
+ p = self.make()
+ url = f"/projects/{p['id']}/items/add/"
+ a = self.post(url, {'kind': 'book', 'ref': 12, 'data_ct': 'b', 'data_iv': IV}).json()
+ b = self.post(url, {'kind': 'book', 'ref': 12, 'data_ct': 'b2', 'data_iv': IV}).json()
+ self.assertEqual((a['existing'], b['existing'], a['item']['id']), (False, True, b['item']['id']))
+ self.post(url, {'kind': 'note', 'data_ct': 'n', 'data_iv': IV})
+ self.post(url, {'kind': 'note', 'ref': 5, 'data_ct': 'n', 'data_iv': IV}) # a note has no ref
+ self.post(url, {'kind': 'episode', 'ref': 7, 'data_ct': 'e', 'data_iv': IV})
+ self.assertEqual(self.post(url, {'kind': 'episode', 'data_ct': 'e', 'data_iv': IV}).status_code, 400)
+ got = self.client.get(f"/projects/{p['id']}/").json()
+ self.assertEqual([(i['kind'], i['ref']) for i in got['items']],
+ [('book', 12), ('note', None), ('note', None), ('episode', 7)])
+ links = self.client.get('/projects/').json()['links']
+ self.assertEqual(sorted((l['kind'], l['ref']) for l in links), [('book', 12), ('episode', 7)])
+
+ def test_update_with_base_and_delete(self):
+ p = self.make()
+ i = self.post(f"/projects/{p['id']}/items/add/", {'kind': 'note', 'data_ct': 'v1', 'data_iv': IV}).json()['item']
+ r = self.post(f"/projects/items/{i['id']}/", {'data_ct': 'v2', 'data_iv': 'b2' * 12, 'base_iv': IV})
+ self.assertEqual(r.status_code, 200)
+ r = self.post(f"/projects/items/{i['id']}/", {'data_ct': 'v3', 'data_iv': 'c3' * 12, 'base_iv': IV})
+ self.assertEqual((r.status_code, r.json()['item']['data_ct']), (409, 'v2'))
+ self.post(f"/projects/items/{i['id']}/delete/")
+ self.assertFalse(ProjectItem.objects.exists())
+
+
+class DoiTests(Base):
+ def test_recognised(self):
+ self.assertEqual(doi_of('https://doi.org/10.1038/s41586-020-2649-2.'), '10.1038/s41586-020-2649-2')
+ self.assertEqual(doi_of('https://arxiv.org/abs/2401.01234v2'), '10.48550/arXiv.2401.01234')
+ self.assertEqual(doi_of('2401.01234'), '10.48550/arXiv.2401.01234')
+ self.assertIsNone(doi_of('kein doi'))
+
+ def test_lookup_trimmed_and_cached(self):
+ class R:
+ status_code = 200
+
+ def json(self):
+ return {'title': 'Ein Paper', 'DOI': '10.1234/x', 'reference': [1, 2, 3], 'author': [{'family': 'A'}]}
+ with patch('projects.views.requests.get', return_value=R()) as get:
+ a = self.client.get('/projects/doi/', {'q': 'doi:10.1234/x'}).json()
+ self.client.get('/projects/doi/', {'q': '10.1234/X'})
+ self.assertEqual(get.call_count, 1)
+ self.assertEqual(a['csl']['title'], 'Ein Paper')
+ self.assertNotIn('reference', a['csl'])
+ self.assertEqual(self.client.get('/projects/doi/', {'q': 'nichts'}).status_code, 400)
diff --git a/projects/urls.py b/projects/urls.py
new file mode 100644
index 0000000..92d4dd4
--- /dev/null
+++ b/projects/urls.py
@@ -0,0 +1,18 @@
+from django.urls import path
+
+from . import radar, views
+
+urlpatterns = [
+ path('', views.projects, name='projects'),
+ path('create/', views.create, name='projects_create'),
+ path('doi/', views.doi, name='projects_doi'),
+ path('/', views.project, name='projects_project'),
+ path('/delete/', views.delete, name='projects_delete'),
+ path('/radar/', radar.radar_view, name='projects_radar'),
+ path('/radar/settings/', radar.radar_settings, name='projects_radar_settings'),
+ path('/radar/run/', radar.radar_run, name='projects_radar_run'),
+ path('/radar/import/', radar.radar_import, name='projects_radar_import'),
+ path('/items/add/', views.item_add, name='projects_item_add'),
+ path('items//', views.item_update, name='projects_item_update'),
+ path('items//delete/', views.item_delete, name='projects_item_delete'),
+]
diff --git a/projects/views.py b/projects/views.py
new file mode 100644
index 0000000..c5fa312
--- /dev/null
+++ b/projects/views.py
@@ -0,0 +1,285 @@
+"""Projekte (docs/projects.md): the API. Everything a project says is encrypted on the device;
+here it's stored and handed back. A write names the IV it changed (base_iv): changed
+elsewhere since, it's refused with the current state (409) for the device to merge."""
+import json
+import re
+
+import requests
+from django.core.cache import cache
+from django.db import IntegrityError, transaction
+from django.http import JsonResponse
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_http_methods
+
+from .models import Project, ProjectItem
+
+MAX_DATA = 2_000_000 # characters of base64: a long draft, not a PDF
+MAX_PROJECTS = 200
+MAX_ITEMS = 5000
+
+
+def _auth(request):
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ return None
+
+
+def _body(request):
+ try:
+ body = json.loads(request.body or b'{}')
+ except (json.JSONDecodeError, ValueError):
+ return None
+ return body if isinstance(body, dict) else None
+
+
+def _hex_iv(value):
+ return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value)
+
+
+def _blob(body):
+ """(data_ct, data_iv) from a request, or None."""
+ ct, iv = body.get('data_ct'), body.get('data_iv')
+ if not isinstance(ct, str) or not ct or len(ct) > MAX_DATA or not _hex_iv(iv):
+ return None
+ return ct, iv
+
+
+def _project_json(p, counts=None):
+ return {'id': p.id, 'preset': p.preset, 'data_ct': p.data_ct, 'data_iv': p.data_iv, 'archived': p.archived,
+ 'position': p.position, 'created_at': p.created_at.isoformat(), 'updated_at': p.updated_at.isoformat(),
+ 'items': (counts or {}).get(p.id, 0)}
+
+
+def _item_json(i):
+ return {'id': i.id, 'project': i.project_id, 'kind': i.kind, 'ref': i.ref, 'data_ct': i.data_ct,
+ 'data_iv': i.data_iv, 'created_at': i.created_at.isoformat(), 'updated_at': i.updated_at.isoformat()}
+
+
+def _project(request, pk):
+ return Project.objects.filter(pk=pk, user=request.user).first()
+
+
+# ---- projects ------------------------------------------------------------------------------
+
+@require_http_methods(['GET'])
+def projects(request):
+ """All projects, and every link (kind, ref, project) — what a book's or an episode's sheet
+ shows as "in Projekt …"."""
+ err = _auth(request)
+ if err:
+ return err
+ rows = list(Project.objects.filter(user=request.user))
+ counts = {}
+ links = []
+ for pid, kind, ref in ProjectItem.objects.filter(project__user=request.user).values_list('project_id', 'kind', 'ref'):
+ counts[pid] = counts.get(pid, 0) + 1
+ if ref is not None:
+ links.append({'project': pid, 'kind': kind, 'ref': ref})
+ return JsonResponse({'projects': [_project_json(p, counts) for p in rows], 'links': links,
+ 'presets': [k for k, _ in Project.PRESETS]})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def create(request):
+ err = _auth(request)
+ if err:
+ return err
+ body = _body(request)
+ blob = body and _blob(body)
+ if not blob:
+ return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
+ preset = body.get('preset', Project.RESEARCH)
+ if preset not in dict(Project.PRESETS):
+ return JsonResponse({'error': 'unknown preset'}, status=400)
+ if Project.objects.filter(user=request.user).count() >= MAX_PROJECTS:
+ return JsonResponse({'error': 'too many projects'}, status=400)
+ p = Project.objects.create(user=request.user, preset=preset, data_ct=blob[0], data_iv=blob[1])
+ return JsonResponse({'project': _project_json(p)})
+
+
+@csrf_exempt
+@require_http_methods(['GET', 'POST'])
+def project(request, pk):
+ """GET: the project with its items. POST: new data (with base_iv), archived, position."""
+ err = _auth(request)
+ if err:
+ return err
+ p = _project(request, pk)
+ if not p:
+ return JsonResponse({'error': 'not found'}, status=404)
+ if request.method == 'GET':
+ items = list(p.items.all())
+ return JsonResponse({'project': _project_json(p, {p.id: len(items)}), 'items': [_item_json(i) for i in items]})
+ body = _body(request)
+ if body is None:
+ return JsonResponse({'error': 'invalid JSON'}, status=400)
+ fields = []
+ if 'data_ct' in body:
+ blob = _blob(body)
+ if not blob:
+ return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
+ base = body.get('base_iv')
+ if base and base != p.data_iv:
+ return JsonResponse({'error': 'stale', 'project': _project_json(p)}, status=409)
+ p.data_ct, p.data_iv = blob
+ fields += ['data_ct', 'data_iv']
+ if 'archived' in body:
+ p.archived = bool(body['archived'])
+ fields.append('archived')
+ if 'position' in body:
+ try:
+ p.position = int(body['position'])
+ except (TypeError, ValueError):
+ return JsonResponse({'error': 'position'}, status=400)
+ fields.append('position')
+ if fields:
+ p.save(update_fields=fields + ['updated_at'])
+ return JsonResponse({'project': _project_json(p)})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def delete(request, pk):
+ err = _auth(request)
+ if err:
+ return err
+ p = _project(request, pk)
+ if not p:
+ return JsonResponse({'error': 'not found'}, status=404)
+ p.delete()
+ return JsonResponse({'ok': True})
+
+
+# ---- items ---------------------------------------------------------------------------------
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def item_add(request, pk):
+ """{kind, ref?, data_ct, data_iv}. A book, episode or article is in a project once: adding
+ it again answers with the item there (existing: true)."""
+ err = _auth(request)
+ if err:
+ return err
+ p = _project(request, pk)
+ if not p:
+ return JsonResponse({'error': 'not found'}, status=404)
+ body = _body(request)
+ blob = body and _blob(body)
+ if not blob:
+ return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
+ kind = body.get('kind')
+ if kind not in dict(ProjectItem.KINDS):
+ return JsonResponse({'error': 'kind'}, status=400)
+ ref = body.get('ref')
+ if kind in ProjectItem.LINKED:
+ if not isinstance(ref, int) or isinstance(ref, bool) or ref <= 0:
+ return JsonResponse({'error': 'ref required'}, status=400)
+ else:
+ ref = None
+ if p.items.count() >= MAX_ITEMS:
+ return JsonResponse({'error': 'too many items'}, status=400)
+ if ref is not None:
+ have = p.items.filter(kind=kind, ref=ref).first()
+ if have:
+ return JsonResponse({'item': _item_json(have), 'existing': True})
+ try:
+ with transaction.atomic():
+ i = ProjectItem.objects.create(project=p, kind=kind, ref=ref, data_ct=blob[0], data_iv=blob[1])
+ except IntegrityError:
+ return JsonResponse({'item': _item_json(p.items.get(kind=kind, ref=ref)), 'existing': True})
+ p.save(update_fields=['updated_at'])
+ return JsonResponse({'item': _item_json(i), 'existing': False})
+
+
+def _item(request, pk):
+ return ProjectItem.objects.filter(pk=pk, project__user=request.user).select_related('project').first()
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def item_update(request, pk):
+ """{data_ct, data_iv, base_iv}: a changed note or source; 409 with the current one when it
+ was changed elsewhere since."""
+ err = _auth(request)
+ if err:
+ return err
+ i = _item(request, pk)
+ if not i:
+ return JsonResponse({'error': 'not found'}, status=404)
+ body = _body(request)
+ blob = body and _blob(body)
+ if not blob:
+ return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
+ base = body.get('base_iv')
+ if base and base != i.data_iv:
+ return JsonResponse({'error': 'stale', 'item': _item_json(i)}, status=409)
+ i.data_ct, i.data_iv = blob
+ i.save(update_fields=['data_ct', 'data_iv', 'updated_at'])
+ i.project.save(update_fields=['updated_at'])
+ return JsonResponse({'item': _item_json(i)})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def item_delete(request, pk):
+ err = _auth(request)
+ if err:
+ return err
+ i = _item(request, pk)
+ if not i:
+ return JsonResponse({'error': 'not found'}, status=404)
+ i.delete()
+ return JsonResponse({'ok': True})
+
+
+# ---- a paper's data by its DOI ----------------------------------------------------------------
+
+DOI_RE = re.compile(r'(10\.\d{4,9}/[^\s"<>]+)', re.I)
+
+
+def doi_of(text):
+ """The DOI in a DOI, a doi.org link, a publisher's link that carries it, or an arXiv id."""
+ text = (text or '').strip()
+ m = DOI_RE.search(text)
+ if m:
+ return m.group(1).rstrip('.,;)')
+ m = re.search(r'arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})|^(?:arxiv:)?(\d{4}\.\d{4,5})(?:v\d+)?$', text, re.I)
+ if m:
+ return '10.48550/arXiv.' + (m.group(1) or m.group(2))
+ return None
+
+
+@require_http_methods(['GET'])
+def doi(request):
+ """?q=: the work's citation data (CSL-JSON) from doi.org — Crossref,
+ DataCite (arXiv) and the other registries answer there. Not kept beyond a cache."""
+ err = _auth(request)
+ if err:
+ return err
+ d = doi_of(request.GET.get('q', ''))
+ if not d:
+ return JsonResponse({'error': 'Keine DOI erkannt.'}, status=400)
+ key = 'doi:' + re.sub(r'\W+', '_', d.lower())[:200]
+ csl = cache.get(key)
+ if csl is None:
+ try:
+ r = requests.get('https://doi.org/' + d, timeout=15,
+ headers={'Accept': 'application/vnd.citationstyles.csl+json',
+ 'User-Agent': 'diora (https://diora.creamfresh.xyz)'})
+ except requests.RequestException:
+ return JsonResponse({'error': 'doi.org nicht erreichbar.'}, status=502)
+ if r.status_code == 404:
+ return JsonResponse({'error': 'DOI unbekannt.'}, status=404)
+ try:
+ csl = r.json()
+ except ValueError:
+ return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502)
+ if not isinstance(csl, dict):
+ return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502)
+ # What a citation needs; the reference lists and licences stay out.
+ for k in ('reference', 'license', 'link', 'relation', 'assertion', 'funder', 'content-domain',
+ 'indexed', 'deposited', 'update-policy', 'resource'):
+ csl.pop(k, None)
+ cache.set(key, csl, 7 * 24 * 3600)
+ return JsonResponse({'doi': d, 'csl': csl})
diff --git a/radio/streamcheck.py b/radio/streamcheck.py
new file mode 100644
index 0000000..ad59e5d
--- /dev/null
+++ b/radio/streamcheck.py
@@ -0,0 +1,84 @@
+"""Which stream addresses the server may open for track titles (/radio/sse/, docs/radio.md).
+
+- Never an address inside a private network (loopback, 10.x, 172.16-31.x, 192.168.x,
+ link-local, the Docker network …): the server would otherwise reach what only it can see.
+- Without an account only the directory's stations: the page's curated lists, the featured
+ ones, and those radio-browser.info lists (by its station id, or by the address). Bringing an address of
+ one's own is for saving it, and saving needs an account.
+"""
+import functools
+import hashlib
+import ipaddress
+import re
+import socket
+from pathlib import Path
+from urllib.parse import urlparse
+
+import requests
+from django.core.cache import cache
+
+from .models import FeaturedStation
+
+DIRECTORY = 'https://de1.api.radio-browser.info/json/stations'
+# The page's curated lists (SomaFM, SWR2 …) are part of the directory too; their addresses
+# are read from where the page has them, so they stand in one place only.
+CURATED_JS = Path(__file__).resolve().parent.parent / 'static' / 'js' / 'app' / '16-stations.js'
+DIRECTORY_TIMEOUT = 6
+DIRECTORY_CACHE = 24 * 3600
+
+
+def public_address(url):
+ """True when url is http(s) and every address its host resolves to is a public one."""
+ try:
+ parsed = urlparse(url)
+ except ValueError:
+ return False
+ if parsed.scheme not in ('http', 'https') or not parsed.hostname:
+ return False
+ try:
+ infos = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80),
+ proto=socket.IPPROTO_TCP)
+ except (OSError, UnicodeError, ValueError):
+ return False
+ addresses = {info[4][0] for info in infos}
+ return bool(addresses) and all(ipaddress.ip_address(a.split('%')[0]).is_global for a in addresses)
+
+
+def _directory_lists(url, uuid):
+ if uuid:
+ try:
+ r = requests.get(f'{DIRECTORY}/byuuid/{uuid}', timeout=DIRECTORY_TIMEOUT,
+ headers={'User-Agent': 'diora/1.0'})
+ if r.ok and any(url in (s.get('url'), s.get('url_resolved')) for s in r.json()):
+ return True
+ except (requests.RequestException, ValueError):
+ pass
+ try:
+ r = requests.get(f'{DIRECTORY}/byurl', params={'url': url}, timeout=DIRECTORY_TIMEOUT,
+ headers={'User-Agent': 'diora/1.0'})
+ return bool(r.ok and r.json())
+ except (requests.RequestException, ValueError):
+ return False
+
+
+@functools.lru_cache(maxsize=1)
+def curated():
+ try:
+ return frozenset(re.findall(r"url: '([^']+)'", CURATED_JS.read_text(encoding='utf-8')))
+ except OSError:
+ return frozenset()
+
+
+def in_directory(url, uuid='', own_host=''):
+ """A station of the directory: curated on the page, featured, ours (the creamfresh stream
+ through this server), or listed by radio-browser.info. Answers are kept a day."""
+ if url in curated() or FeaturedStation.objects.filter(url=url, active=True).exists():
+ return True
+ if own_host and urlparse(url).netloc == own_host:
+ return True
+ key = 'radio:dir:' + hashlib.sha1(f'{url} {uuid}'.encode()).hexdigest()
+ known = cache.get(key)
+ if known is None:
+ known = _directory_lists(url, uuid)
+ cache.set(key, known, DIRECTORY_CACHE)
+ return known
diff --git a/radio/tests.py b/radio/tests.py
new file mode 100644
index 0000000..813f4fd
--- /dev/null
+++ b/radio/tests.py
@@ -0,0 +1,100 @@
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+
+class PlayerPagePodcastsTests(TestCase):
+ """The podcast tab is the app's podcast page (static/podcasts/, copied from
+ diora-android) on a browser host (static/js/podcasts-web.js)."""
+
+ def test_logged_in_page_loads_the_podcast_host(self):
+ user = User.objects.create_user('pod', password='pw-for-tests-only')
+ self.client.force_login(user)
+ html = self.client.get('/').content.decode()
+ self.assertIn('id="tab-podcasts"', html)
+ self.assertIn('', html)
+ self.assertIn('dpw-loading', html)
+ # The old podcast tab and its player bar are gone.
+ self.assertNotIn('podcast-toolbar', html)
+ self.assertNotIn('podcast-seek-bar', html)
+ self.assertNotIn('INITIAL_PODCAST_FEEDS', html)
+
+ def test_logged_out_page_asks_to_log_in(self):
+ html = self.client.get('/').content.decode()
+ self.assertIn('um Podcasts zu abonnieren.', html)
+ self.assertNotIn('dpw-loading', html)
+
+ def test_podcast_ui_files_are_there(self):
+ static = Path(settings.BASE_DIR) / 'static'
+ host = (static / 'js' / 'podcasts-web.js').read_text(encoding='utf-8')
+ for name in ('native-podcast.js', 'podcast-ui.css', 'podcasts-web.css'):
+ path = static / 'podcasts' / name
+ self.assertTrue(path.exists(), name)
+ if name != 'podcasts-web.css':
+ self.assertIn('Copied from diora-android', path.read_text(encoding='utf-8')[:300])
+ self.assertIn('/static/podcasts/' + name, host)
+ sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8')
+ for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'):
+ self.assertIn(path, sw)
+
+
+class SseGuardTests(TestCase):
+ """/radio/sse/ opens a stream for its titles: never in a private network, and without an
+ account only the directory's stations (radio/streamcheck.py, docs/radio.md)."""
+
+ PUBLIC = [(2, 1, 6, '', ('93.184.216.34', 80))]
+ STREAM = 'http://stream.example.org/live.mp3'
+
+ def setUp(self):
+ from django.core.cache import cache
+ cache.clear()
+
+ def sse(self, url, **more):
+ from unittest import mock
+ with mock.patch('radio.views.stream_icy_metadata', return_value=iter(['Artist - Title'])):
+ return self.client.get('/radio/sse/', {'url': url, **more})
+
+ def test_private_addresses_never(self):
+ User.objects.create_user('r', password='pw12345678')
+ self.client.login(username='r', password='pw12345678')
+ for url in ['http://127.0.0.1:8000/', 'http://10.0.0.5/stream', 'http://169.254.169.254/latest/',
+ 'http://[::1]/x', 'file:///etc/passwd', 'gopher://example.org/']:
+ self.assertEqual(self.sse(url).status_code, 403, url)
+
+ def test_without_account_only_the_directory(self):
+ from unittest import mock
+ with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC):
+ with mock.patch('radio.streamcheck.requests.get') as get:
+ get.return_value = mock.Mock(ok=True, json=lambda: [])
+ self.assertEqual(self.sse(self.STREAM).status_code, 403) # not listed: an own address
+ listed = [{'url': 'http://stream.example.org/pls', 'url_resolved': self.STREAM}]
+ get.return_value = mock.Mock(ok=True, json=lambda: listed)
+ self.assertEqual(self.sse(self.STREAM, station='abc-uuid').status_code, 200)
+
+ def test_featured_stations_without_account(self):
+ from unittest import mock
+ from .models import FeaturedStation
+ FeaturedStation.objects.create(name='F', url=self.STREAM)
+ with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), \
+ mock.patch('radio.streamcheck.requests.get') as get:
+ self.assertEqual(self.sse(self.STREAM).status_code, 200)
+ get.assert_not_called()
+
+ def test_own_addresses_with_an_account(self):
+ from unittest import mock
+ User.objects.create_user('r', password='pw12345678')
+ self.client.login(username='r', password='pw12345678')
+ with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), \
+ mock.patch('radio.streamcheck.requests.get') as get:
+ self.assertEqual(self.sse(self.STREAM).status_code, 200)
+ get.assert_not_called()
+
+ def test_the_pages_curated_stations_without_account(self):
+ from unittest import mock
+ from . import streamcheck
+ self.assertIn('https://ice6.somafm.com/dronezone-256-mp3', streamcheck.curated())
+ with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), mock.patch('radio.streamcheck.requests.get') as get:
+ self.assertEqual(self.sse('https://ice6.somafm.com/dronezone-256-mp3').status_code, 200)
+ get.assert_not_called()
diff --git a/radio/views.py b/radio/views.py
index 12b26c3..8453d17 100644
--- a/radio/views.py
+++ b/radio/views.py
@@ -12,6 +12,7 @@ from django.conf import settings
from django.http import (
HttpResponse,
HttpResponseBadRequest,
+ HttpResponseForbidden,
JsonResponse,
StreamingHttpResponse,
)
@@ -22,6 +23,7 @@ from django.views.decorators.http import require_http_methods
from .icy import stream_icy_metadata
from . import lastfm as lastfm_module
+from . import streamcheck
from .models import SavedStation, StationPlay, TrackHistory, FocusSession, FeaturedStation
@@ -63,13 +65,6 @@ 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:
@@ -91,7 +86,6 @@ def index(request):
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
'featured_stations': featured,
'featured_stations_json': json.dumps(featured, cls=DjangoJSONEncoder),
- 'initial_podcast_feeds': json.dumps(initial_podcast_feeds, cls=DjangoJSONEncoder),
'focus_station': focus_station,
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
'encrypted_bg': encrypted_bg,
@@ -106,9 +100,16 @@ def index(request):
@csrf_exempt
def sse_metadata(request):
+ """The stream's track titles. Never from a private network; without an account only for
+ the directory's stations (radio/streamcheck.py, docs/radio.md)."""
url = request.GET.get('url', '').strip()
if not url:
return HttpResponseBadRequest('url parameter required')
+ if not streamcheck.public_address(url):
+ return HttpResponseForbidden('not a public stream address')
+ if not request.user.is_authenticated and not streamcheck.in_directory(
+ url, request.GET.get('station', '').strip()[:64], request.get_host()):
+ return HttpResponseForbidden('own stream addresses need an account')
def event_stream():
last_title = None
@@ -373,15 +374,16 @@ def stop_play(request):
# ---------------------------------------------------------------------------
def _time_context_label(hour, is_weekend):
- prefix = 'weekend' if is_weekend else 'weekday'
+ # Shown as "Was du {label} oft hörst:".
+ prefix = 'am Wochenende' if is_weekend else 'unter der Woche'
if 5 <= hour <= 11:
- period = 'morning'
+ period = 'morgens'
elif 12 <= hour <= 16:
- period = 'afternoon'
+ period = 'nachmittags'
elif 17 <= hour <= 21:
- period = 'evening'
+ period = 'abends'
else:
- period = 'night'
+ period = 'nachts'
return f'{prefix} {period}'
diff --git a/requirements.txt b/requirements.txt
index 40bc49d..7296680 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,3 +17,11 @@ gevent==26.8.0
gunicorn==26.2.0
packaging==26.3
piper-tts==1.7.0
+# ffmpeg (a static binary in the wheel) finds the pauses for skipping silence in the browser.
+imageio-ffmpeg==0.6.0
+# The feed reader takes the article out of a news page (main text without menus, ads, comments).
+trafilatura==2.3.0
+# Subscriber logins of the feed reader are kept encrypted (feeds/logins.py).
+cryptography==50.0.1
+# The Radar of a project sorts the week's papers with Claude (Batch API, projects/radar.py).
+anthropic==1.15.0
diff --git a/static/ambient/index.html b/static/ambient/index.html
new file mode 100644
index 0000000..8817e3b
--- /dev/null
+++ b/static/ambient/index.html
@@ -0,0 +1,1374 @@
+
+
+
+
+
+Drift – Ambient Synth
+
+
+
+
+
+
+
Drift
+
Generativer Ambient-Synthesizer · alles wird live im Browser erzeugt
`;
+ wrap.addEventListener('click', e => {
+ if (e.target.closest('.dai-gear')) { settings(); return; }
+ if (e.target === wrap || e.target.closest('.dai-close')) close();
+ });
+ document.body.append(wrap);
+ return wrap.querySelector('.dai-body');
+}
+
+function close() {
+ const s = document.getElementById('dai-sheet');
+ if (s) s.remove();
+ return !!s;
+}
+
+async function settings(after) {
+ const body = sheet('KI-Assistent', true);
+ const v = (await loadVault(true)) || {};
+ let provider = providerOf(v);
+ const draw = () => {
+ const p = PROVIDERS[provider];
+ const has = !!v[provider + '_key'];
+ body.innerHTML = `
+
diora kann mit Claude Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
+ Du entscheidest bei jedem Vorschlag, ob er übernommen wird.
Dein eigener Key, mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server kann ihn nicht
+ lesen. Deine Geräte fragen ${esc(p.label)} direkt; ${provider === 'openrouter' ? 'OpenRouter und Anthropic sehen' : 'Anthropic sieht'}
+ dabei, was eine Funktion braucht (z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet
+ ${esc(p.label)} mit dir ab. Einen Key legst du unter ${esc(p.helpLabel)} an.