Compare commits

...

14 commits

Author SHA1 Message Date
e8bb70feb0 Eingebaute Whisper-Instanz: kleiner OpenAI-kompatibler Server (lädt das Modell bei Bedarf, gibt es nach Leerlauf frei)
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:10:15 +01:00
21cf91a875 Podcast-Oberfläche aus diora-android (Teilen, Transkript erstellen, ✦ Artikel, Artikel-Tab, Einstellungen Transkription), sw CACHE v170
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:21:42 +01:00
d4a47064b2 Podcast-Artikel: Reservierung mit Gerätenamen (claim/release, deviceId pro Browser oder App)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:03:19 +01:00
cedc14f714 Merge branch 'wt-tr-pipeline' into wt-tr-int 2026-11-09 10:02:00 +01:00
ae5341d441 Smarte Transkription C: Transkriptions-Pipeline (podcasts/transcribe.py), Endpunkte /transcribe/, /transcription/pending/, /article/claim/, Transkript-Rückfall auf Whisper, transcripts in /feeds/notifications/
Die Folge wird geladen, zu 16 kHz mono dekodiert und an Pausen in Stücke von etwa acht Minuten geschnitten (FLAC für die eingebaute, Opus für fremde Instanzen), parallel an Whisper geschickt (drei Wiederholungen mit wachsender Pause) und per transcripts.merge geglättet. Ein Daemon-Thread arbeitet die Warteschlange ab, ein Auftrag zugleich; hängengebliebene Aufträge starten nach einem Neustart beim ersten Aufruf neu. Abbrechen löscht die Zeile des Auftrags. Gerät der Reservierung: device im Body oder Header X-Diora-Device (docs/transcription.md).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:01:35 +01:00
9659140f30 Migration der Transkription nach 0028 (nach dem Episodenlink aus dem Teilen-Schritt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:47:20 +01:00
d059ed82c8 Merge branch 'wt-tr-article' into wt-tr-int 2026-11-09 09:39:08 +01:00
efdc0ffc11 Merge branch 'wt-tr-blog' into wt-tr-int 2026-11-09 09:39:08 +01:00
bd09d81366 Smarte Transkription D: Artikel-Endpunkt und Blog-Anbindung
POST/DELETE /podcasts/episodes/<id>/article/ (podcasts/article.py): legt die Quelle Podcast-Artikel an und erzeugt oder ersetzt das Item der Folge; Sanitizer behält href=#t=<Sekunden>; _item_data und Item-Ansicht liefern episode_id; feeds.js: Folge anhören und Zeitmarken-Links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:36:34 +01:00
a937ed8b3e Folgen haben einen Link: PodcastEpisode.link aus dem <link> des Feed-Eintrags (neu und beim Abruf nachgetragen), in episode_detail und allen Folgenlisten
Grundlage fürs Teilen einer Folge; Migration 0027.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:32:13 +01:00
69442f4a4d Smarte Transkription B: Datenmodell, Einstellungen und Test-Endpunkt
TranscriptionSettings (Token Fernet-verschluesselt wie SiteLogin), TranscriptionJob und EpisodeArticle in podcasts; Source.synthetic in feeds, das Refresh, Aufraeumen, OPML-Export, Kommando und Umstellen auf Nachrichten ueberspringt bzw. verweigert. GET/POST /podcasts/transcription/settings/ und POST /podcasts/transcription/test/ (SSRF-Pruefung ueber chapters._open, jetzt auch fuer POST; eingebaute Instanz aus DIORA_WHISPER_URL ausgenommen).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:27:01 +01:00
810cf7741c Podcast-Artikel: Benachrichtigungsziel episode:<id> wie in der Android-Bridge, im Vertrag festgehalten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:22:06 +01:00
dbd4fb6e3a Podcast-Artikel auf dem Gerät: podcast-article.js schreibt den Artikel mit dem eigenen Key und holt offene Wünsche ab; DioraAI.ask mit onProgress und signal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:21:24 +01:00
f242605a97 Smarte Transkription: Vertrag und Prompt (docs/transcription.md), Abschnitt Transkripte in docs/podcasts.md, Podcast-Artikel in docs/ai.md
Transkript und Artikel nur auf Knopfdruck; der Server transkribiert mit Whisper (eingebaut oder eigene Instanz), das Gerät schreibt den Artikel mit dem eigenen Schlüssel, sofort oder beim nächsten Öffnen; Benachrichtigung über /feeds/notifications/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:16:16 +01:00
37 changed files with 3864 additions and 63 deletions

10
deploy/whisper/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
# diora's built-in Whisper (docs/transcription.md). Built on the server next to diora-prod:
# docker compose build whisper
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir faster-whisper==1.2.1 fastapi==0.143.1 uvicorn==0.54.0 python-multipart==0.0.32
WORKDIR /app
COPY server.py .
ENV WHISPER_CACHE=/models HF_HOME=/models/hf
EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]

78
deploy/whisper/server.py Normal file
View file

@ -0,0 +1,78 @@
"""diora's built-in Whisper: a small OpenAI-compatible transcription server.
POST /v1/audio/transcriptions (multipart: file, model, language, prompt, response_format)
returns {text} or, with response_format=verbose_json, {language, duration, text, segments}.
The `model` field is ignored; WHISPER_MODEL decides. The model is loaded on the first request
and freed again after WHISPER_IDLE seconds without work, so it holds no memory while unused.
One transcription at a time; parallel requests wait (the server has few cores to share).
See docs/transcription.md.
"""
import os
import subprocess
import threading
import time
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from faster_whisper import WhisperModel
MODEL = os.environ.get('WHISPER_MODEL', 'large-v3-turbo')
THREADS = int(os.environ.get('WHISPER_THREADS', '4'))
IDLE = int(os.environ.get('WHISPER_IDLE', '600'))
CACHE = os.environ.get('WHISPER_CACHE', '/models')
app = FastAPI()
work = threading.Lock() # one transcription at a time
state = {'model': None, 'last': 0.0}
def model():
if state['model'] is None:
state['model'] = WhisperModel(MODEL, device='cpu', compute_type='int8', cpu_threads=THREADS,
download_root=CACHE)
return state['model']
def unloader():
while True:
time.sleep(30)
if state['model'] is not None and time.time() - state['last'] > IDLE and work.acquire(blocking=False):
try:
state['model'] = None
finally:
work.release()
threading.Thread(target=unloader, daemon=True).start()
def decode(data: bytes) -> np.ndarray:
p = subprocess.run(['ffmpeg', '-nostdin', '-loglevel', 'error', '-i', 'pipe:0', '-ac', '1', '-ar', '16000',
'-f', 'f32le', 'pipe:1'], input=data, capture_output=True)
if p.returncode != 0:
raise HTTPException(400, 'not audio: ' + p.stderr.decode('utf-8', 'replace')[-200:])
return np.frombuffer(p.stdout, np.float32)
@app.get('/health')
def health():
return {'ok': True, 'model': MODEL, 'loaded': state['model'] is not None}
@app.post('/v1/audio/transcriptions')
def transcriptions(file: UploadFile = File(...), language: str = Form(''), prompt: str = Form(''),
response_format: str = Form('json')):
audio = decode(file.file.read())
t0 = time.time()
with work:
segs, info = model().transcribe(audio, language=language or None, initial_prompt=prompt or None,
beam_size=5, vad_filter=True)
segs = [{'id': i, 'start': round(s.start, 2), 'end': round(s.end, 2), 'text': s.text}
for i, s in enumerate(segs)]
state['last'] = time.time()
dur = len(audio) / 16000
print(f'{dur:.0f} s audio in {time.time() - t0:.0f} s', flush=True)
text = ''.join(s['text'] for s in segs).strip()
if response_format == 'verbose_json':
return {'task': 'transcribe', 'language': info.language, 'duration': dur, 'text': text, 'segments': segs}
return {'text': text}

View file

@ -84,6 +84,9 @@ DATABASES = {
}
}
# The built-in Whisper instance (OpenAI-compatible, base such as http://whisper:8000/v1);
# empty = none (docs/transcription.md).
WHISPER_URL = os.environ.get('DIORA_WHISPER_URL', '').strip().rstrip('/')
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
AUTH_PASSWORD_VALIDATORS = [

View file

@ -29,6 +29,8 @@ kept by the server as a blob it can't read:
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).
Podcast articles wait on the server as a wish until a device with the key is open
(docs/transcription.md).
## Building blocks (`static/js/ai.js`, an ES module)
@ -41,6 +43,8 @@ kept by the server as a blob it can't read:
(`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.
- `ask` also takes `onProgress(words)` (words written so far while streaming; OpenRouter is not streamed and reports once at the end)
and `signal` (an `AbortSignal`), both optional; `DioraAI.modelName()` is the model of the chosen provider. Used by the podcast articles.
- `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
@ -53,5 +57,6 @@ kept by the server as a blob it can't read:
| 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 |
| Podcast-Artikel | ✦ Artikel in an episode (`static/js/podcast-article.js`, docs/transcription.md) | the episode's transcript (feed or Whisper), title, author, shownotes, chapters | an article from the speakers' standpoint with summary and sections, saved to the episode and to Blogs (no review sheet: it is new text, removable) |
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).

View file

@ -182,3 +182,12 @@ Listening without committing: one button plays the Strom like a station.
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
previous time as `state.stream_seen_before` for the marks).
## Transcripts
An episode's Transkript tab shows one of two kinds. **From the feed**: `<podcast:transcript>`
(SRT, VTT, JSON, Podlove), fetched and parsed on first use and cached in `EpisodeTranscript`
(`podcasts/transcripts.py`, `GET /podcasts/episodes/<id>/transcript/`). **Made by Whisper**
(since 2026-11): only when the user asks, kept on `TranscriptionJob`; the feed's wins when both
exist. From a transcript the device can write an article that is pinned to the episode and shows
in Blogs. All of that is in docs/transcription.md.

244
docs/transcription.md Normal file
View file

@ -0,0 +1,244 @@
# Smarte Transkription: Transkript und Artikel zu einer Folge
Decided with the user on 2026-11-09. Plan and order of work:
diora-android `plans/smarte-transkription.md`.
## What it is for
Sometimes the shownotes don't tell whether an episode still has something to teach. Then the
user presses a button and, a few hours later at most, gets a readable article written from the
speakers' own standpoint. It is meant to let them judge the episode for themselves, so it keeps
arguments, reasons and evidence and does not just summarise them. The article sits on the
episode and in Blogs.
Nothing happens on its own. The server never transcribes ahead, by cron or on subscribing; the
only way to start a transcription is the `POST` below, sent when the user presses a button.
## Who does what
The **server** transcribes. It already downloads and decodes episodes for skipping silence
(`podcasts/silence.py`), and browsers and the WebView can't fetch podcast audio (CORS). It cuts
the audio at pauses and sends the pieces in parallel to a Whisper instance with an
OpenAI-compatible API (`POST /v1/audio/transcriptions`): the built-in one by default (a container
beside diora-web, `DIORA_WHISPER_URL`), or any instance the user enters. YouTube episodes are
not transcribed: the server never fetches their sound (docs/youtube.md).
The **device** writes the article with the user's own key (docs/ai.md). The key never reaches the
server. It is one LLM call of a few minutes. If diora is open when the transcript is done, it
happens right away; otherwise the next time diora is opened on any of the user's devices. The
server only remembers that an article was wanted.
Transcript and article are stored in plain text on the server, like all podcast and feed data.
## Flow
1. In the episode the user presses "Transkript erstellen" (the Transkript tab) or
"✦ Artikel erstellen" / "✦ Transkribieren und Artikel erstellen". The second sends
`article: true`. With a transcript already there (from the feed or an earlier run) only the
article is written.
2. The server works through the episode (minutes to hours, no GPU on Strato). The UI shows
"Transkribiere 7/38 …" while it is open.
3. Done: a notice for `GET /feeds/notifications/`. Android's BackgroundSync and the desktop show
"Transkript fertig: <Folge>", with "Artikel wird beim Öffnen geschrieben" when one is wanted.
Tapping it opens diora at the episode.
4. A device with a key (`static/js/podcast-article.js`) picks up the open wishes, reserves one,
writes the article and saves it. It then says "Artikel fertig" (on Android as a system
notification through the bridge, `DioraNative.notify(title, text, target)` with target
`episode:<id>` or `item:<id>`; elsewhere a toast) and opens it on a tap.
5. The article is the first tab of the episode ("Artikel") and an item of the favourite blog
"Podcast-Artikel", linking back to the episode.
## API
**Settings.** `GET /podcasts/transcription/settings/` returns
`{engine: "builtin"|"custom", url, model, language, parallel, has_token, builtin_available}`.
`POST` takes the same fields and `token` (write-only; `""` removes it). The token is kept
encrypted with Fernet like `feeds.SiteLogin.secret`. `language` is `""` for automatic or an ISO
code (`de`). `parallel` is 1 to 8, default 3. `builtin_available` is whether `DIORA_WHISPER_URL`
is set. `url` is the instance's base, e.g. `https://whisper.example.org/v1`; the server appends
`/audio/transcriptions`.
An empty `model` is sent as `whisper-1`. The built-in URL is read into `settings.WHISPER_URL` from
`DIORA_WHISPER_URL`. A custom engine needs a `url`; the token is only sent to custom instances.
`POST /podcasts/transcription/test/` sends one second of silence to the configured instance and
returns `{ok, model, error}`. A custom URL goes through the same SSRF check as chapters
(`podcasts/chapters.py` `_open`): no private, loopback or link-local addresses. Only the built-in
URL from `DIORA_WHISPER_URL` is exempt.
**Job.** `POST /podcasts/episodes/<id>/transcribe/` with `{article: true|false}` starts a job, or
updates `want_article` on a running one. With a transcript already there it returns
`{state: "done"}` straight away (and keeps `want_article`). `GET` on the same address returns
`{state, chunks_done, chunks_total, error, want_article, started_at, finished_at}`, with `state`
one of `none`, `queued`, `downloading`, `transcribing`, `done`, `error`. `DELETE` cancels a
queued or running job (its row is deleted, so `GET` then says `none`; a finished one stays). The server runs one job at a time; the others wait as `queued`. A YouTube
episode returns 400 `{error: "youtube"}`, an episode over 800 MB 400 `{error: "too large"}`. The size is asked with a `HEAD`
request (`Content-Length`; if that fails the job starts anyway) and checked again while
downloading, where it ends the job with the error `too large`. `article: true` is only ever
raised, never lowered, by a repeated `POST`; a `POST` after an `error` starts a new job (at the
back of the queue). Jobs run in id order, which is why the notice below can use ids as its cursor.
A feed transcript already there gives a job that is `done` with no `data`: it only holds the wish
for an article and causes no `transcripts` notice.
**Transcript.** The result is kept on the job (`TranscriptionJob.data`) in the format of
`EpisodeTranscript.data`: `{segments: [{start, end, speaker, text}], source: {type: "whisper",
model, language}}`. `speaker` is `""`. It is kept apart from `EpisodeTranscript`, which is a cache
of the feed's transcripts and is rewritten when the feed changes them.
`GET /podcasts/episodes/<id>/transcript/` returns the feed's transcript when the feed has a
usable one, else the Whisper one, else 404 as before. `episode_detail` adds
`transcript_origin` (`"feed"`, `"whisper"` or `null`), `has_transcript` is true for both, and it
adds `article_item_id` (or `null`) and `transcription` (the job's state as above, or `null`).
**Article.** `POST /podcasts/episodes/<id>/article/` takes `{title, summary, body_html, model,
kind}` and returns `{item_id}`. The server cleans `body_html` with `feeds/fetch.py clean_html`.
It creates the user's source "Podcast-Artikel" when missing (`kind` blog, `fav` true,
`synthetic` true, `url` `diora://podcast-artikel`) and adds a `feeds.Item` there, or replaces the
episode's earlier one. Item fields: the episode's link (else its audio URL) as `url`, the feed's
author as `author`, the episode's artwork as `image_url`, now as `published` (so it is on top of
the blog stream under "heute"), `words` counted. Saving clears the wish. `DELETE` on the same
address removes the article and its item. `GET /feeds/items/<id>/` and `_item_data` carry
`episode_id` for such items.
`body_html` may contain `href="#t=<seconds>"` links; `clean_html` keeps exactly this fragment form
(`podcasts/shownotes.py` `TIME_LINK`) and no other `#` links. Validation: `title` 1 to 300
characters, `body_html` non-empty after cleaning and at most 1,000,000 characters, `summary` at most
2000, `model` at most 100, `kind` at most 20 (400 otherwise). The item has `content_full` true (no
page is ever fetched) and, as the reader's furniture filter would strip repeated "▶ ab" blocks,
`GET /feeds/items/<id>/` skips that filter for synthetic sources. The endpoint is
`podcasts/article.py`.
Synthetic sources are skipped by the feed refresh (`refresh_newsfeeds`, `refresh_now`) and by the
pruning of old blog items (`feeds/refresh.py`). They can be hidden or have their bell turned off
like any blog; they can't be refreshed, renamed to another URL or turned into news. `refresh.refresh()` itself drops them, the OPML export leaves them out, and `_source_data` carries `synthetic`.
**Open wishes.** `GET /podcasts/transcription/pending/` returns
`[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done,
that want an article, have none and aren't reserved. `POST
/podcasts/episodes/<id>/article/claim/` reserves one for 15 minutes for the calling device
(200), or answers 409 when another device holds it, so phone and desktop don't both write it.
`DELETE` on the claim releases it (the device gave up, e.g. no key or an error the user saw).
The device names itself with `device` in the JSON body (or, if that is missing, the
`X-Diora-Device` header): a short stable name such as `android`, `desktop` or `browser`, at most 60
characters; a claim without one is 400. The same device claiming again renews its 15 minutes. A
claim on an episode with no open wish (no job done, no article wanted, or an article already
there) is 404. `DELETE` by a device that does not hold the claim is 409.
**Notice.** `GET /feeds/notifications/` additionally takes `t_after` and returns
`transcripts: [{job_id, episode_id, title, podcast, want_article}]` (jobs done after that id) and
`t_last`. Without `t_after` (a device's first time) the list is empty and only `t_last` comes,
like `after`/`last`. Devices that don't know the fields ignore them.
## Transcribing (`podcasts/transcribe.py`)
Download with the same limits as `silence.py`, decode with imageio-ffmpeg to 16 kHz mono. Cut
into pieces of about eight minutes; each cut moves to the longest pause within ±30 seconds
(`silence.py`'s detection), so no word is cut. Pieces go out as FLAC to the built-in instance
and as Opus 32 kbit/s to custom ones, to keep uploads small.
Each piece is sent with `model`, `language` (when set), `response_format=verbose_json` and a
`prompt` of podcast title, episode title and author names, so Whisper spells names right. Pieces
run in a thread pool of `parallel` workers; each is retried up to three times with a growing
pause; progress is written after each. Segment times are shifted by the piece's start; the
segments are joined and smoothed with `transcripts.merge`. A job runs in a daemon thread, like
`silence.ensure()`; one per server. A job found `downloading` or `transcribing` after a restart
is started again. This happens when the first
request (`GET`/`POST` on `/transcribe/` or `/transcription/pending/`) after a start reaches the server.
Cancelling deletes the job's row; the worker notices at the latest after five seconds, stops
sending pieces and removes its temporary files (they live in a temporary directory that is always
removed). Only the worker's main thread writes to the database. The settings used are those of the
episode's owner, read when the job starts. A refusal by the instance with a 4xx status other than
408 and 429 is not retried and ends the job with `Whisper: HTTP <code>`.
## The article (`static/js/podcast-article.js`)
Input: the transcript as plain text, one line per segment group of about 30 seconds,
`[mm:ss] Sprecher: text` (or `[mm:ss] text` without speakers), plus the episode's metadata:
podcast title, author, episode title, date, duration, the shownotes as plain text and the
chapters. Over about 120,000 tokens (more than about nine hours) the device stops with a clear
message for now. A five-hour episode is about 70,000 tokens.
`DioraAI.ask({system, prompt, schema, effort: "medium", maxTokens: 32000, onProgress, signal})`.
`onProgress(words)` reports the words written so far while streaming ("Schreibe Artikel … 2.300
Wörter"); `signal` aborts. Both are optional and new in ai.js; the existing callers don't change.
Schema:
```json
{
"type": "object",
"additionalProperties": false,
"required": ["title", "summary", "kind", "speakers", "sections"],
"properties": {
"title": {"type": "string"},
"summary": {"type": "string"},
"kind": {"type": "string", "enum": ["Interview", "Gespräch", "Monolog", "Diskussion"]},
"speakers": {"type": "array", "items": {"type": "string"}},
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["heading", "start", "body_markdown"],
"properties": {
"heading": {"type": "string"},
"start": {"type": "integer"},
"body_markdown": {"type": "string"}
}
}
}
}
}
```
The HTML: the summary as a lead paragraph, then per section an `<h2>`, below it a link
"▶ ab 12:34" (`<a href="#t=754">`; `clean_html` keeps fragment links, and feeds.js plays the
item's `episode_id` from there when such a link is tapped) and the body. Markdown is limited to paragraphs, emphasis, lists and block
quotes, converted on the device; the server cleans the result anyway.
System prompt (the user's words decide the perspective; keep its substance when changing it):
```text
You turn the transcript of a podcast episode into an article that reads as if the people
speaking had written it themselves instead of recording the episode.
The reader wants to decide whether this episode still has something to teach them. They do not
want a summary; they want to understand what is argued here well enough to judge it for
themselves. Write for that reader.
Perspective. Write from the speakers' standpoint and state their positions directly, as their
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
is one person speaking, write as that person argues, in the third person or impersonal voice,
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
soften what is said.
Conversations. Interviews, conversations and discussions are condensed into their core theses;
the form of question and answer disappears. Where people hold different positions, keep them
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
minutes. Questions from a host matter only where they shape the argument. When the transcript
has no speaker names, infer them from the context (introductions, the shownotes, how people
address each other); if you can't, do not invent names.
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
title. Make clear what is new, distinctive or surprising in the episode and where it restates
what is commonly said, by how you weight and order it, without grading it.
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
lead nowhere.
Form. Write in the language of the episode. `title`: a title for the article, not the episode
title repeated. `summary`: three to six sentences up front that state the central thesis and how
the episode gets there. `sections`: as many as the content needs (usually four to ten), each
with a heading that says something (not "Einleitung"), `start` = the second in the episode where
its content begins (from the [mm:ss] marks), and `body_markdown` in plain paragraphs; lists only
where the speakers list things themselves. Follow the order of the episode unless a different
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
roughly a tenth of the transcript, at most about 5,000 words.
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
using the metadata, and don't quote garbled passages.
```
Prompt (user turn): the metadata block, then `Transkript:` and the text.

View file

@ -13,7 +13,7 @@ class Command(BaseCommand):
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
def handle(self, *args, **options):
qs = Source.objects.select_related('user').order_by('last_refreshed_at')
qs = Source.objects.filter(synthetic=False).select_related('user').order_by('last_refreshed_at')
if options['source_id']:
qs = qs.filter(pk=options['source_id'])
else:

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-11-09 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0006_folder_order'),
]
operations = [
migrations.AddField(
model_name='source',
name='synthetic',
field=models.BooleanField(default=False),
),
]

View file

@ -35,6 +35,9 @@ class Source(models.Model):
fav = models.BooleanField(default=False)
hidden = models.BooleanField(default=False)
notify = models.BooleanField(default=False)
# Filled by diora itself (the blog "Podcast-Artikel"), not by a feed: never
# fetched, pruned, given another URL or turned into news.
synthetic = models.BooleanField(default=False)
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
last_refreshed_at = models.DateTimeField(null=True, blank=True)

View file

@ -216,7 +216,7 @@ def expire(user, now=None):
Item.objects.filter(user=user, gone=True, published__lt=now - timedelta(days=GONE_KEEP_DAYS)).delete()
Story.objects.filter(user=user).exclude(items__gone=False).delete()
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
for source in Source.objects.filter(user=user, kind=Source.BLOG):
for source in Source.objects.filter(user=user, kind=Source.BLOG, synthetic=False):
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
if ids:
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
@ -225,7 +225,7 @@ def expire(user, now=None):
def refresh(sources, deadline=None):
"""Refresh `sources` (downloads in parallel, writes one by one).
Returns (refreshed, failed, new items)."""
sources = list(sources)
sources = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch
now = timezone.now()
keep = {}
refreshed = failed = 0

View file

@ -539,3 +539,56 @@ class NewsTextApiTest(TestCase):
self.assertEqual(got['related'], [])
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
self.assertNotIn('summary', single)
class SyntheticSourceTest(TestCase):
"""A source diora fills itself ("Podcast-Artikel") is never fetched or pruned."""
def setUp(self):
self.u = User.objects.create_user('tester', password='pw12345678')
self.client.force_login(self.u)
self.synth = Source.objects.create(user=self.u, url='diora://podcast-artikel', title='Podcast-Artikel',
kind='blog', fav=True, synthetic=True)
self.real = Source.objects.create(user=self.u, url='https://real.example/feed', title='Real', kind='blog')
def post(self, url, data):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_refresh_skips_it(self):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
refresh.refresh([self.synth, self.real])
self.assertEqual([c.args[0] for c in get.call_args_list], ['https://real.example/feed'])
def test_refresh_now_and_command_skip_it(self):
with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([]), 'https://real.example/feed')) as get:
r = self.post('/feeds/refresh/', {})
from django.core.management import call_command
call_command('refresh_newsfeeds', stdout=mock.MagicMock())
self.assertEqual(r.status_code, 200)
self.assertTrue(get.call_args_list)
self.assertTrue(all(c.args[0] == 'https://real.example/feed' for c in get.call_args_list))
call_command('refresh_newsfeeds', '--source-id', str(self.synth.id), stdout=mock.MagicMock())
def test_old_items_are_not_pruned(self):
old = timezone.now() - timedelta(days=400)
for source in (self.synth, self.real):
for n in range(refresh.BLOG_KEEP + 5):
Item.objects.create(source=source, user=self.u, kind='blog', guid_hash=f'{source.pk}-{n}',
title='x', published=old - timedelta(minutes=n), read_at=old)
refresh.expire(self.u)
self.assertEqual(self.synth.items.count(), refresh.BLOG_KEEP + 5)
self.assertEqual(self.real.items.count(), refresh.BLOG_KEEP)
def test_cannot_become_news_but_can_be_hidden(self):
r = self.post(f'/feeds/sources/{self.synth.id}/', {'kind': 'news'})
self.assertEqual(r.status_code, 400)
r = self.post(f'/feeds/sources/{self.synth.id}/', {'hidden': True, 'notify': True})
self.assertEqual(r.status_code, 200)
self.synth.refresh_from_db()
self.assertEqual((self.synth.kind, self.synth.hidden, self.synth.notify), ('blog', True, True))
self.assertTrue(r.json()['source']['synthetic'])
def test_not_in_opml(self):
body = self.client.get('/feeds/export.opml').content.decode()
self.assertIn('real.example', body)
self.assertNotIn('diora://', body)

View file

@ -93,7 +93,7 @@ def _source_data(s, unread=0, new=0, interval=None):
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
'unread': unread, 'new': new,
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify,
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'synthetic': s.synthetic,
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
'interval_days': interval,
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
@ -108,6 +108,11 @@ def _item_data(i, content=False):
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
'starred': i.starred, 'story': i.story_id,
}
if i.source.synthetic:
# The article of a podcast episode (docs/transcription.md); the lists fetch it with the item.
art = getattr(i, 'episode_article', None)
if art is not None:
d['episode_id'] = art.episode_id
if content:
d['content'] = i.content
d['content_full'] = i.content_full
@ -356,7 +361,7 @@ def add_sources(request):
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
title=(x.get('title') or url)[:300]))
added += created
sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
sources = Source.objects.filter(user=request.user, last_refreshed_at=None, synthetic=False)
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'added': added})
@ -386,6 +391,8 @@ def edit_source(request, pk):
if body.get('fulltext') in dict(Source.FULLTEXTS):
source.fulltext = body['fulltext']
new_kind = _kind(body.get('kind'))
if source.synthetic and new_kind == Source.NEWS:
return JsonResponse({'error': 'Diese Quelle bleibt ein Blog'}, status=400)
if new_kind and new_kind != source.kind:
# A section becomes a folder and back.
if new_kind == Source.BLOG and source.category and not source.folders:
@ -416,7 +423,7 @@ def export_opml(request):
if (err := _auth(request)):
return err
groups = {}
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
for s in Source.objects.filter(user=request.user, synthetic=False).order_by('kind', 'category', 'title'):
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
label = kind + (f' – {place}' if place else '')
@ -440,7 +447,7 @@ def export_opml(request):
def refresh_now(request):
if (err := _auth(request)):
return err
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user),
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user, synthetic=False),
deadline=time.monotonic() + REFRESH_WAIT)
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
@ -611,7 +618,7 @@ def story_overview(request, pk):
def blogs(request):
if (err := _auth(request)):
return err
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source')
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source', 'episode_article')
if request.GET.get('source', '').isdigit():
qs = qs.filter(source_id=int(request.GET['source']))
elif 'folder' in request.GET:
@ -640,7 +647,7 @@ def blogs(request):
def starred(request):
if (err := _auth(request)):
return err
items = Item.objects.filter(user=request.user, starred=True).select_related('source').order_by('-published')
items = Item.objects.filter(user=request.user, starred=True).select_related('source', 'episode_article').order_by('-published')
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
@ -649,13 +656,14 @@ def starred(request):
def item(request, pk):
if (err := _auth(request)):
return err
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source').first()
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source', 'episode_article').first()
if i is None:
return JsonResponse({'error': 'not found'}, status=404)
if refresh.needs_article(i, i.source):
refresh.load_article(i)
d = _item_data(i, content=True)
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
if not i.source.synthetic:
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
return JsonResponse({'item': d})
@ -744,17 +752,32 @@ def star(request, pk):
def notifications(request):
"""What a device should announce: posts marked when they came in, still
unread, of blogs that still have the bell on, after the id the device saw
last. Without `after` (a device's first time) nothing, only where to start."""
last. Without `after` (a device's first time) nothing, only where to start.
The same for finished transcripts (`t_after`, `transcripts`, `t_last`; jobs the
user's episodes have, docs/transcription.md)."""
if (err := _auth(request)):
return err
qs = Item.objects.filter(user=request.user, notify=True)
last = qs.order_by('-id').values_list('id', flat=True).first() or 0
# Jobs run in id order, one at a time, so a job never finishes below an id already seen.
# (A job that is done with no data only reflects a feed transcript: no notice for it.)
from podcasts.models import TranscriptionJob
jobs = TranscriptionJob.objects.filter(
episode__feed__user=request.user, state=TranscriptionJob.DONE, data__isnull=False)
t_last = jobs.order_by('-id').values_list('id', flat=True).first() or 0
out = {'items': [], 'last': last, 'transcripts': [], 't_last': t_last}
t_after = request.GET.get('t_after', '')
if t_after.isdigit():
out['transcripts'] = [{
'job_id': j.id, 'episode_id': j.episode_id, 'title': j.episode.title,
'podcast': j.episode.feed.title, 'want_article': j.want_article,
} for j in jobs.filter(id__gt=int(t_after)).select_related('episode__feed').order_by('id')[:50]]
after = request.GET.get('after', '')
if not after.isdigit():
return JsonResponse({'items': [], 'last': last})
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
.select_related('source').order_by('-published')[:50])
return JsonResponse({'items': [_item_data(i) for i in items], 'last': last})
if after.isdigit():
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
.select_related('source').order_by('-published')[:50])
out['items'] = [_item_data(i) for i in items]
return JsonResponse(out)
@csrf_exempt
@ -770,6 +793,8 @@ def save_login(request):
password = body.get('password') or ''
if source is None or not username or not password:
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
if source.synthetic:
return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400)
try:
adapter = logins.check_and_store(request.user, source.url, username, password)
except logins.LoginFailed as e:

119
podcasts/article.py Normal file
View file

@ -0,0 +1,119 @@
"""The article written from an episode's transcript (docs/transcription.md): its text
is a feeds.Item in the user's favourite blog "Podcast-Artikel"."""
import hashlib
import json
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 feeds import fetch
from feeds.models import Item, Source
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob
SOURCE_URL = 'diora://podcast-artikel'
SOURCE_TITLE = 'Podcast-Artikel'
TITLE_MAX = 300
SUMMARY_MAX = 2000
BODY_MAX = 1_000_000 # characters of body_html
MODEL_MAX = 100
KIND_MAX = 20
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 (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else None
def article_source(user):
"""The user's blog "Podcast-Artikel", made on first use."""
source, _ = Source.objects.get_or_create(user=user, url=SOURCE_URL, defaults={
'title': SOURCE_TITLE, 'kind': Source.BLOG, 'fav': True, 'synthetic': True})
return source
def _text(value, limit):
return value.strip() if isinstance(value, str) and len(value.strip()) <= limit else None
@csrf_exempt
@require_http_methods(['POST', 'DELETE'])
def episode_article(request, pk):
if (err := _auth(request)):
return err
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).select_related('feed').first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
if request.method == 'DELETE':
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
if art is not None:
art.item.delete() # the article goes with it (cascade)
return JsonResponse({'ok': True})
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
title = _text(body.get('title'), TITLE_MAX)
if not title:
return JsonResponse({'error': 'title needed (at most %d characters)' % TITLE_MAX}, status=400)
html_in = body.get('body_html')
if not isinstance(html_in, str) or not html_in.strip():
return JsonResponse({'error': 'body_html needed'}, status=400)
if len(html_in) > BODY_MAX:
return JsonResponse({'error': 'body_html too large'}, status=400)
summary = body.get('summary') or ''
if not isinstance(summary, str) or len(summary) > SUMMARY_MAX:
return JsonResponse({'error': 'summary too long'}, status=400)
model = body.get('model') or ''
kind = body.get('kind') or ''
if not isinstance(model, str) or len(model) > MODEL_MAX or not isinstance(kind, str) or len(kind) > KIND_MAX:
return JsonResponse({'error': 'model or kind too long'}, status=400)
url = getattr(ep, 'link', '') or ep.audio_url
content = fetch.clean_html(html_in, url)
if not fetch.plain(content):
return JsonResponse({'error': 'body_html empty'}, status=400)
fields = {
'url': url[:2000],
'title': title,
'author': (ep.feed.author or '')[:300],
'published': timezone.now(),
'summary': fetch.plain(summary),
'content': content,
'content_full': True, # nothing to fetch from a page
'words': fetch.word_count(content),
'image_url': (ep.artwork_url or ep.feed.artwork_url or '')[:2000],
'read_at': None, 'progress': 0, 'progress_at': None,
}
with transaction.atomic():
source = article_source(request.user)
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
if art is not None:
item = art.item
for k, v in fields.items():
setattr(item, k, v)
item.source = source
item.save()
art.model, art.kind = model, kind
art.save(update_fields=['model', 'kind'])
else:
guid = hashlib.sha256(f'diora-article:{ep.pk}'.encode()).hexdigest()
item = Item.objects.create(source=source, user=request.user, kind=Source.BLOG,
guid_hash=guid, **fields)
EpisodeArticle.objects.create(episode=ep, item=item, model=model, kind=kind)
# Saving fulfils the wish and frees the reservation.
TranscriptionJob.objects.filter(episode=ep).update(want_article=False, claimed_by='', claimed_until=None)
return JsonResponse({'item_id': item.id})

View file

@ -128,25 +128,28 @@ FetchUnavailable = ChaptersUnavailable
FetchInvalid = ChaptersInvalid
def _open(url, headers):
"""GET `url` with every redirect hop checked; the streamed response of
the final hop, which the caller has to close.
def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
"""`method` (GET or POST) `url` with every redirect hop checked; the streamed response of
the final hop, which the caller has to close. `kwargs` (data, files) go to
requests; a POST only follows 307/308, which keep the method and body.
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
ones that may go away (network trouble, server errors).
"""
redirects = (301, 302, 303, 307, 308) if method == 'GET' else (307, 308)
for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
try:
assert_safe_url(url)
except WebDAVInputError as e:
raise ChaptersInvalid(str(e))
try:
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
send = requests.get if method == 'GET' else requests.post
r = send(url, timeout=timeout, allow_redirects=False, stream=True, headers=headers, **kwargs)
except requests.RequestException as e:
raise ChaptersUnavailable(e.__class__.__name__)
try:
_assert_peer_is_safe(r)
if r.status_code in (301, 302, 303, 307, 308):
if r.status_code in redirects:
location = r.headers.get('Location')
if not location:
raise ChaptersInvalid('redirect without a location')
@ -155,7 +158,7 @@ def _open(url, headers):
continue
if r.status_code >= 500 or r.status_code == 429:
raise ChaptersUnavailable(f'HTTP {r.status_code}')
if r.status_code >= 400:
if r.status_code >= 400 or r.status_code in (301, 302, 303):
raise ChaptersInvalid(f'HTTP {r.status_code}')
except WebDAVInputError as e:
r.close()

View file

@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-11-09 08:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0026_podcastepisode_sponsor'),
]
operations = [
migrations.AddField(
model_name='podcastepisode',
name='link',
field=models.URLField(blank=True, max_length=1000),
),
]

View file

@ -0,0 +1,59 @@
# Generated by Django 6.1 on 2026-11-09 08:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feeds', '0007_transcription'),
('podcasts', '0027_podcastepisode_link'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EpisodeArticle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model', models.CharField(blank=True, max_length=100)),
('kind', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='article', to='podcasts.podcastepisode')),
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='episode_article', to='feeds.item')),
],
),
migrations.CreateModel(
name='TranscriptionJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('none', 'none'), ('queued', 'queued'), ('downloading', 'downloading'), ('transcribing', 'transcribing'), ('done', 'done'), ('error', 'error')], default='none', max_length=15)),
('chunks_done', models.PositiveIntegerField(default=0)),
('chunks_total', models.PositiveIntegerField(default=0)),
('error', models.CharField(blank=True, max_length=500)),
('want_article', models.BooleanField(default=False)),
('claimed_by', models.CharField(blank=True, max_length=60)),
('claimed_until', models.DateTimeField(blank=True, null=True)),
('data', models.JSONField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_job', to='podcasts.podcastepisode')),
],
),
migrations.CreateModel(
name='TranscriptionSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('engine', models.CharField(choices=[('builtin', 'Eingebaut'), ('custom', 'Eigene Instanz')], default='builtin', max_length=10)),
('url', models.CharField(blank=True, max_length=500)),
('token_encrypted', models.TextField(blank=True)),
('model', models.CharField(blank=True, max_length=100)),
('language', models.CharField(blank=True, max_length=10)),
('parallel', models.PositiveSmallIntegerField(default=3)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_settings', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -164,6 +164,8 @@ class PodcastEpisode(models.Model):
title = models.CharField(max_length=500)
description = models.TextField(blank=True)
audio_url = models.URLField(max_length=1000)
# The episode's page, from the feed item's <link> (for sharing); '' when it has none.
link = models.URLField(max_length=1000, blank=True)
duration_seconds = models.IntegerField(default=0)
pub_date = models.DateTimeField(null=True, blank=True)
episode_number = models.IntegerField(null=True, blank=True)
@ -217,6 +219,68 @@ class EpisodeTranscript(models.Model):
return f'Transcript of {self.episode}'
class TranscriptionSettings(models.Model):
"""Where a user's episodes are transcribed (docs/transcription.md): the
built-in Whisper instance (DIORA_WHISPER_URL) or their own."""
BUILTIN = 'builtin'
CUSTOM = 'custom'
ENGINES = [(BUILTIN, 'Eingebaut'), (CUSTOM, 'Eigene Instanz')]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='transcription_settings')
engine = models.CharField(max_length=10, choices=ENGINES, default=BUILTIN)
# The instance's base, e.g. https://whisper.example.org/v1 (custom only).
url = models.CharField(max_length=500, blank=True)
# The API token, Fernet-encrypted like feeds.SiteLogin.secret; never handed out.
token_encrypted = models.TextField(blank=True)
model = models.CharField(max_length=100, blank=True)
# '' = automatic, else an ISO code ("de").
language = models.CharField(max_length=10, blank=True)
parallel = models.PositiveSmallIntegerField(default=3) # 1 to 8 pieces at once
def __str__(self):
return f'Transcription settings of {self.user}'
class TranscriptionJob(models.Model):
"""One episode's transcription run, its Whisper result and the wish for an
article. Kept apart from EpisodeTranscript, which caches the feed's
transcripts and is rewritten when the feed changes them."""
NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR = (
'none', 'queued', 'downloading', 'transcribing', 'done', 'error')
STATES = [(s, s) for s in (NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR)]
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcription_job')
state = models.CharField(max_length=15, choices=STATES, default=NONE)
chunks_done = models.PositiveIntegerField(default=0)
chunks_total = models.PositiveIntegerField(default=0)
error = models.CharField(max_length=500, blank=True)
want_article = models.BooleanField(default=False)
# The device writing the article (a short name) and until when it holds the wish.
claimed_by = models.CharField(max_length=60, blank=True)
claimed_until = models.DateTimeField(null=True, blank=True)
# {"segments": [{start, end, speaker, text}], "source": {type: "whisper", model, language}}
data = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f'Transcription of {self.episode} ({self.state})'
class EpisodeArticle(models.Model):
"""The article written from an episode's transcript; its text is the
feeds.Item (in the blog "Podcast-Artikel")."""
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='article')
item = models.OneToOneField('feeds.Item', on_delete=models.CASCADE, related_name='episode_article')
model = models.CharField(max_length=100, blank=True)
kind = models.CharField(max_length=20, blank=True) # Interview, Gespräch, Monolog, Diskussion
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Article on {self.episode}'
# An episode counts as played this close to its end (AntennaPod's default),
# the same rule as the apps' "als gehört zählen".
PLAYED_BEFORE_END_SECONDS = 30

View file

@ -37,6 +37,9 @@ ALLOWED_ATTRS = {
URL_ATTRS = {'href', 'src'}
LINK_SCHEMES = {'http', 'https', 'mailto'}
IMAGE_SCHEMES = {'http', 'https'}
# A link to a second in the episode ("#t=754"), as in the article of a transcript
# (docs/transcription.md): the only fragment link that is kept.
TIME_LINK = re.compile(r'^#t=\d{1,6}$')
_CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+')
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
@ -81,7 +84,9 @@ class _Sanitizer(HTMLParser):
name = name.lower()
if name not in allowed or value is None:
continue
if name in URL_ATTRS:
if tag == 'a' and name == 'href' and TIME_LINK.match(value.strip()):
value = value.strip()
elif name in URL_ATTRS:
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
if value is None:
continue

146
podcasts/test_article.py Normal file
View file

@ -0,0 +1,146 @@
import json
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from feeds.models import Item, Source
from .models import EpisodeArticle, PodcastEpisode, PodcastFeed, TranscriptionJob
BODY = ('<p>Zusammenfassung <script>alert(1)</script></p><h2>Teil eins</h2>'
'<p><a href="#t=754" onclick="evil()">▶ ab 12:34</a> und <a href="https://example.org/x">Quelle</a></p>')
class ArticleTests(TestCase):
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://p.example/rss', title='Salon',
author='Anna Autorin', artwork_url='https://p.example/feed.jpg')
self.ep = PodcastEpisode.objects.create(feed=self.feed, guid='e1', title='Folge 1',
audio_url='https://p.example/e1.mp3', artwork_url='https://p.example/e1.jpg')
def url(self, ep=None):
return f'/podcasts/episodes/{(ep or self.ep).id}/article/'
def post(self, data=None, ep=None):
data = {'title': 'Ein Artikel', 'summary': 'Kurz.', 'body_html': BODY, 'model': 'claude', 'kind': 'Interview'} \
if data is None else data
return self.client.post(self.url(ep), json.dumps(data), content_type='application/json')
def test_requires_login_and_ownership(self):
self.client.logout()
self.assertEqual(self.post().status_code, 401)
self.assertEqual(self.client.delete(self.url()).status_code, 401)
bob = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(bob)
self.assertEqual(self.post().status_code, 404)
self.assertEqual(self.client.delete(self.url()).status_code, 404)
self.assertFalse(Item.objects.exists())
def test_create(self):
r = self.post()
self.assertEqual(r.status_code, 200)
item = Item.objects.get(pk=r.json()['item_id'])
self.assertEqual(item.user, self.user)
self.assertEqual(item.kind, Source.BLOG)
self.assertEqual(item.title, 'Ein Artikel')
self.assertEqual(item.summary, 'Kurz.')
self.assertEqual(item.author, 'Anna Autorin')
self.assertEqual(item.image_url, 'https://p.example/e1.jpg')
self.assertEqual(item.url, 'https://p.example/e1.mp3')
self.assertTrue(item.content_full)
self.assertGreater(item.words, 3)
self.assertLess((timezone.now() - item.published).total_seconds(), 60)
art = EpisodeArticle.objects.get(episode=self.ep)
self.assertEqual((art.item, art.model, art.kind), (item, 'claude', 'Interview'))
self.assertEqual(item.episode_article, art)
def test_image_falls_back_to_feed_artwork(self):
self.ep.artwork_url = ''
self.ep.save()
item = Item.objects.get(pk=self.post().json()['item_id'])
self.assertEqual(item.image_url, 'https://p.example/feed.jpg')
def test_sanitizer_keeps_time_links_only(self):
item = Item.objects.get(pk=self.post().json()['item_id'])
self.assertNotIn('<script', item.content)
self.assertNotIn('alert(1)', item.content)
self.assertNotIn('onclick', item.content)
self.assertIn('href="#t=754"', item.content)
self.assertIn('href="https://example.org/x"', item.content)
evil = self.post({'title': 'x', 'body_html': '<a href="#t=1&quot; onfocus=&quot;x">a</a><a href="#top">b</a>'})
content = Item.objects.get(pk=evil.json()['item_id']).content
self.assertNotIn('onfocus', content)
self.assertNotIn('#top', content)
def test_source_made_once(self):
other = PodcastEpisode.objects.create(feed=self.feed, guid='e2', title='Folge 2', audio_url='https://p.example/e2.mp3')
self.post()
self.post(ep=other)
src = Source.objects.get(user=self.user, url='diora://podcast-artikel')
self.assertEqual((src.title, src.kind, src.fav, src.synthetic), ('Podcast-Artikel', Source.BLOG, True, True))
self.assertEqual(Source.objects.count(), 1)
self.assertEqual(src.items.count(), 2)
def test_replace_keeps_one_item(self):
first = self.post().json()['item_id']
Item.objects.filter(pk=first).update(read_at=timezone.now(), progress=1)
second = self.post({'title': 'Neu', 'body_html': '<p>Ganz anderer Text</p>', 'model': 'm2'}).json()['item_id']
self.assertEqual(first, second)
self.assertEqual(Item.objects.count(), 1)
item = Item.objects.get()
self.assertEqual((item.title, item.read_at, item.progress), ('Neu', None, 0))
self.assertIn('Ganz anderer Text', item.content)
self.assertEqual(EpisodeArticle.objects.get().model, 'm2')
def test_delete(self):
self.post()
self.assertEqual(self.client.delete(self.url()).status_code, 200)
self.assertFalse(Item.objects.exists())
self.assertFalse(EpisodeArticle.objects.exists())
self.assertEqual(Source.objects.count(), 1)
self.assertEqual(self.client.delete(self.url()).status_code, 200) # again: nothing to do
def test_validation(self):
for data in ({}, {'title': '', 'body_html': BODY}, {'title': 'x' * 301, 'body_html': BODY},
{'title': 'x'}, {'title': 'x', 'body_html': '<script>1</script>'},
{'title': 'x', 'body_html': 'a' * 1_000_001},
{'title': 'x', 'body_html': BODY, 'model': 'm' * 101}):
self.assertEqual(self.post(data).status_code, 400, data and list(data))
self.assertEqual(self.client.post(self.url(), 'nope', content_type='application/json').status_code, 400)
self.assertFalse(Item.objects.exists())
self.assertEqual(self.client.get(self.url()).status_code, 405)
def test_saving_clears_the_wish(self):
TranscriptionJob.objects.create(episode=self.ep, state='done', want_article=True, claimed_by='phone',
claimed_until=timezone.now() + timezone.timedelta(minutes=10))
self.post()
job = TranscriptionJob.objects.get()
self.assertEqual((job.want_article, job.claimed_by, job.claimed_until), (False, '', None))
def test_in_favourite_blogs_with_episode_id(self):
item_id = self.post().json()['item_id']
items = self.client.get('/feeds/blogs/?fav=1').json()['items']
self.assertEqual([i['id'] for i in items], [item_id])
self.assertEqual(items[0]['episode_id'], self.ep.id)
detail = self.client.get(f'/feeds/items/{item_id}/').json()['item']
self.assertEqual(detail['episode_id'], self.ep.id)
self.assertIn('href="#t=754"', detail['content'])
def test_ordinary_items_have_no_episode_id(self):
src = Source.objects.create(user=self.user, url='https://b.example/feed', title='B', kind=Source.BLOG, fav=True)
Item.objects.create(source=src, user=self.user, kind=Source.BLOG, guid_hash='g', title='T',
published=timezone.now(), content='<p>x</p>', content_full=True)
self.post()
items = self.client.get('/feeds/blogs/?fav=1').json()['items']
self.assertEqual(len(items), 2)
self.assertEqual(sorted('episode_id' in i for i in items), [False, True])
def test_blog_list_needs_no_query_per_article(self):
for n in range(5):
self.post(ep=PodcastEpisode.objects.create(feed=self.feed, guid=f'x{n}', title=str(n),
audio_url='https://p.example/x.mp3'))
with self.assertNumQueries(3):
self.client.get('/feeds/blogs/?fav=1')

View file

@ -89,7 +89,7 @@ class PlayerSyncTests(TestCase):
self.assertEqual(d['seq'], 2) # the report, and device a coming online
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
'in_queue'})
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))

45
podcasts/test_share.py Normal file
View file

@ -0,0 +1,45 @@
import feedparser
from django.contrib.auth.models import User
from django.test import TestCase
from .models import PodcastEpisode, PodcastFeed
from .views import FeedFetch, _apply_feed, _entry_link
def _feed(link):
item_link = f'<link>{link}</link>' if link is not None else ''
return feedparser.parse(
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>99 ZU EINS</title>'
'<item><title>Folge 724</title><guid>g724</guid>' + item_link +
'<enclosure url="https://cdn.example/724.mp3" type="audio/mpeg" length="1"/></item>'
'</channel></rss>'
)
class EpisodeLinkTests(TestCase):
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/rss', title='99 ZU EINS')
def fetch(self, link):
_apply_feed(self.feed, FeedFetch(200, _feed(link)))
return PodcastEpisode.objects.get(feed=self.feed)
def test_entry_link_takes_only_web_addresses(self):
self.assertEqual(_entry_link({'link': ' https://x.fireside.fm/724 '}), 'https://x.fireside.fm/724')
self.assertEqual(_entry_link({'link': 'javascript:alert(1)'}), '')
self.assertEqual(_entry_link({}), '')
def test_a_new_episode_gets_the_items_link(self):
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
def test_a_refresh_fills_the_link_of_an_episode_that_has_none(self):
self.assertEqual(self.fetch(None).link, '')
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
def test_episode_detail_returns_the_link(self):
ep = self.fetch('https://99zueins.fireside.fm/724')
r = self.client.get(f'/podcasts/episodes/{ep.id}/')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['link'], 'https://99zueins.fireside.fm/724')

786
podcasts/test_transcribe.py Normal file
View file

@ -0,0 +1,786 @@
"""The transcription pipeline (podcasts/transcribe.py) against a fake Whisper instance, the
endpoints around it and the notice in /feeds/notifications/."""
import email
import io
import json
import math
import os
import struct
import subprocess
import tempfile
import threading
import time
import wave
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from django.contrib.auth.models import User
from django.db import OperationalError
from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone
from feeds.models import Item, Source
from . import transcribe, transcription, youtube
from .models import (
EpisodeArticle, EpisodeTranscript, PodcastEpisode, PodcastFeed, TranscriptionJob, TranscriptionSettings,
)
RATE = 16000
TOTAL = 120
# Where the long pauses are (start, length): the pieces should be cut in these. The short
# ones (0.3 s every 4 s) are only there to tempt.
LONG_PAUSES = [(36.5, 1.6), (83.0, 1.4)]
SEGMENTS = [{'start': 0.0, 'end': 2.0, 'text': ' Hallo '}, {'start': 2.0, 'end': 5.5, 'text': 'Welt.'}]
def make_audio():
"""~2 minutes of tones with pauses, as mp3 bytes (and the wav as a check of the length)."""
samples = []
t = 0.0
pauses = [(s, s + n) for s, n in LONG_PAUSES]
for i in range(RATE * TOTAL):
t = i / RATE
silent = any(a <= t < b for a, b in pauses) or (t % 4.0) > 3.7
value = 0 if silent else int(9000 * math.sin(2 * math.pi * (180 + 40 * ((i // 8000) % 5)) * t))
samples.append(value)
buf = io.BytesIO()
with wave.open(buf, 'wb') as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(struct.pack('<%dh' % len(samples), *samples))
import imageio_ffmpeg
with tempfile.TemporaryDirectory() as tmp:
src, dst = os.path.join(tmp, 'a.wav'), os.path.join(tmp, 'a.mp3')
open(src, 'wb').write(buf.getvalue())
subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', src, '-b:a', '64k', dst], check=True)
return open(dst, 'rb').read()
_AUDIO = []
def audio():
if not _AUDIO:
_AUDIO.append(make_audio())
return _AUDIO[0]
def decoded_seconds(data, suffix):
"""How long the uploaded piece is, by decoding it."""
import imageio_ffmpeg
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, 'p' + suffix)
open(path, 'wb').write(data)
out = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', path, '-f', 's16le',
'-ac', '1', '-ar', str(RATE), '-'], stdout=subprocess.PIPE, check=True).stdout
return len(out) / 2 / RATE
class Fake:
"""A local HTTP server: GET /ep.mp3 is the test audio, POST /v1/audio/transcriptions a Whisper."""
def __init__(self, fail_first=0, fail_status=500, delay=0, on_call=None):
self.fail_first, self.fail_status, self.delay, self.on_call = fail_first, fail_status, delay, on_call
self.calls = [] # {'fields', 'filename', 'body', 'auth'}
self.lock = threading.Lock()
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
if self.path == '/missing.mp3':
self.send_response(404)
self.end_headers()
return
body = audio_holder.data or audio()
self.send_response(200)
self.send_header('Content-Type', 'audio/mpeg')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
raw = self.rfile.read(int(self.headers['Content-Length']))
msg = email.message_from_bytes(
b'Content-Type: ' + self.headers['Content-Type'].encode() + b'\r\n\r\n' + raw)
fields, filename, body = {}, '', b''
for part in msg.get_payload():
name = part.get_param('name', header='content-disposition')
if part.get_filename():
filename, body = part.get_filename(), part.get_payload(decode=True)
else:
fields[name] = part.get_payload(decode=True).decode()
with fake.lock:
fake.calls.append({'fields': fields, 'filename': filename, 'body': body,
'auth': self.headers.get('Authorization')})
n = len(fake.calls)
if fake.on_call:
fake.on_call(n)
if fake.delay:
time.sleep(fake.delay)
if n <= fake.fail_first or fake.fail_first < 0:
self.send_response(fake.fail_status)
self.send_header('Content-Length', '0')
self.end_headers()
return
out = json.dumps({'language': 'german', 'segments': SEGMENTS}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(out)))
self.end_headers()
self.wfile.write(out)
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.server.daemon_threads = True
self.port = self.server.server_address[1]
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def whisper(self):
return f'http://127.0.0.1:{self.port}/v1'
def url(self, path='/ep.mp3'):
return f'http://127.0.0.1:{self.port}{path}'
def stop(self):
self.server.shutdown()
self.server.server_close()
def leftovers():
base = tempfile.gettempdir()
return {d for d in os.listdir(base) if d.startswith('diora-transcribe-')}
FAST = dict(CHUNK_SECONDS=40, CUT_WINDOW=8, MIN_TAIL=10, RETRY_PAUSES=(0, 0, 0))
def wait_for(cond, timeout=30):
end = time.time() + timeout
while time.time() < end:
if cond():
return True
time.sleep(0.05)
return False
class PipelineBase(TransactionTestCase):
def setUp(self):
for k, v in FAST.items():
patcher = patch.object(transcribe, k, v)
patcher.start()
self.addCleanup(patcher.stop)
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast',
author='Anna Autorin')
self.fake = Fake()
self.addCleanup(self.fake.stop)
self.before = leftovers()
def job(self, audio_path='/ep.mp3', **kw):
n = PodcastEpisode.objects.count()
ep = PodcastEpisode.objects.create(feed=self.feed, guid=f'g{n}', title=f'Folge {n}',
audio_url=self.fake.url(audio_path), duration_seconds=TOTAL)
return TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, **kw)
def reload(self, job):
for _ in range(50): # the in-memory test database may be locked for a moment by the worker
try:
return TranscriptionJob.objects.get(pk=job.pk)
except OperationalError:
time.sleep(0.05)
return TranscriptionJob.objects.get(pk=job.pk)
def assertClean(self):
self.assertEqual(leftovers(), self.before)
class PlanTests(PipelineBase):
def test_cuts_land_in_the_long_pauses(self):
with tempfile.TemporaryDirectory() as tmp:
src, pcm = os.path.join(tmp, 'a.mp3'), os.path.join(tmp, 'a.pcm')
open(src, 'wb').write(audio())
transcribe._decode(src, pcm)
total = os.path.getsize(pcm) / 2 / RATE
pieces = transcribe.plan(total, transcribe.pauses_of(pcm))
self.assertAlmostEqual(total, TOTAL, delta=0.5)
self.assertEqual(len(pieces), 3)
cuts = [p[1] for p in pieces[:-1]]
for cut, (start, length) in zip(cuts, LONG_PAUSES):
self.assertTrue(start <= cut <= start + length, f'{cut} not in the pause {start}+{length}')
self.assertEqual(pieces[0][0], 0)
self.assertEqual(pieces[-1][1], total)
self.assertEqual([a[1] for a in pieces[:-1]], [b[0] for b in pieces[1:]])
def test_plan_without_pauses_cuts_at_the_target(self):
self.assertEqual(transcribe.plan(100, []), [(0.0, 40), (40, 80), (80, 100)])
self.assertEqual(transcribe.plan(30, []), [(0.0, 30)])
# a short tail is joined to the last piece
self.assertEqual(transcribe.plan(85, []), [(0.0, 40), (40, 85)])
@override_settings(WHISPER_URL='')
class RunTests(PipelineBase):
def configure(self, **kw):
conf = TranscriptionSettings(user=self.user, **kw)
conf.save()
return conf
def test_builtin_run_end_to_end(self):
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(language='de', parallel=2)
job = self.job()
started = time.time()
transcribe.run(job.pk)
took = time.time() - started
print(f'\n[pipeline run of the {TOTAL} s test audio took {took:.1f} s]')
job = self.reload(job)
self.assertEqual((job.state, job.error), ('done', ''))
self.assertEqual((job.chunks_done, job.chunks_total), (3, 3))
self.assertIsNotNone(job.finished_at)
self.assertEqual(len(self.fake.calls), 3)
for c in self.fake.calls:
self.assertEqual(c['fields']['model'], 'whisper-1')
self.assertEqual(c['fields']['language'], 'de')
self.assertEqual(c['fields']['response_format'], 'verbose_json')
self.assertEqual(c['fields']['prompt'], 'Der Podcast. Folge 0. Anna Autorin')
self.assertTrue(c['filename'].endswith('.flac'))
self.assertIsNone(c['auth']) # the token only goes to custom instances
# the uploaded pieces are the planned ones: the same total, cut at the pauses
lengths = sorted(decoded_seconds(c['body'], '.flac') for c in self.fake.calls)
self.assertAlmostEqual(sum(lengths), TOTAL, delta=0.5)
data = job.data
self.assertEqual(data['source'], {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'})
segs = data['segments']
starts = [s['start'] for s in segs]
self.assertEqual(starts, sorted(starts))
self.assertTrue(all(s['speaker'] == '' for s in segs))
# every piece answered "Hallo Welt." at 0 to 5.5 s: its start is where the piece starts,
# which lies on a cut in a long pause, and the first at zero
self.assertEqual(starts[0], 0)
cuts = starts[1:]
self.assertEqual(len(cuts), 2)
for cut, (p, n) in zip(cuts, LONG_PAUSES):
self.assertTrue(p <= cut <= p + n, f'{cut} not in {p}+{n}')
self.assertEqual(segs[0]['text'], 'Hallo Welt.')
self.assertAlmostEqual(segs[0]['end'], 5.5)
self.assertClean()
def test_language_empty_takes_what_whisper_detects_and_custom_gets_opus_and_token(self):
conf = self.configure(engine='custom', url=self.fake.whisper, model='large-v3', language='')
conf.token_encrypted = transcription.logins.encrypt('tok')
conf.save()
job = self.job()
with patch.object(transcribe.chapters, 'assert_safe_url'), \
patch.object(transcribe.chapters, '_assert_peer_is_safe'):
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('done', ''))
self.assertEqual(job.data['source'], {'type': 'whisper', 'model': 'large-v3', 'language': 'german'})
for c in self.fake.calls:
self.assertTrue(c['filename'].endswith('.ogg'))
self.assertNotIn('language', c['fields'])
self.assertEqual(c['auth'], 'Bearer tok')
self.assertLess(len(c['body']), decoded_seconds(c['body'], '.ogg') * 6500) # 32 kbit/s: about 4 kB a second, ogg and VBR on top
self.assertAlmostEqual(sum(decoded_seconds(c['body'], '.ogg') for c in self.fake.calls), TOTAL, delta=0.5)
self.assertClean()
def test_custom_instance_on_a_private_address_is_refused(self):
self.configure(engine='custom', url=self.fake.whisper)
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertIn('Whisper', job.error)
self.assertEqual(self.fake.calls, [])
@override_settings(WHISPER_URL='')
def test_retries_then_succeeds(self):
self.fake.fail_first = 2
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
job = self.job()
transcribe.run(job.pk)
self.assertEqual(self.reload(job).state, 'done')
self.assertEqual(len(self.fake.calls), 3 + 2)
def test_gives_up_after_three_retries(self):
self.fake.fail_first = -1
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertEqual(job.error, 'Whisper: HTTP 500')
self.assertEqual(len(self.fake.calls), 4) # the first try and three more
self.assertIsNone(job.data)
self.assertClean()
def test_a_refusal_is_not_retried(self):
self.fake.fail_first, self.fake.fail_status = -1, 401
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('error', 'Whisper: HTTP 401'))
self.assertEqual(len(self.fake.calls), 1)
def test_download_failure(self):
with override_settings(WHISPER_URL=self.fake.whisper):
job = self.job('/missing.mp3')
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual((job.state, job.error), ('error', 'Download: HTTP 404'))
self.assertClean()
def test_not_audio(self):
with override_settings(WHISPER_URL=self.fake.whisper), patch.object(audio_holder, 'data', b'<html>no</html>'):
job = self.job()
transcribe.run(job.pk)
job = self.reload(job)
self.assertEqual(job.state, 'error')
self.assertTrue(job.error.startswith('ffmpeg:'), job.error)
self.assertClean()
def test_no_builtin_instance(self):
job = self.job()
transcribe.run(job.pk)
self.assertEqual(self.reload(job).error, 'Keine eingebaute Instanz eingerichtet')
def test_cancel_while_running(self):
job = self.job()
def cancel(n):
if n == 1:
TranscriptionJob.objects.filter(pk=job.pk).delete()
from django.db import connection
connection.close()
self.fake.on_call, self.fake.delay = cancel, 0.2
with override_settings(WHISPER_URL=self.fake.whisper):
self.configure(parallel=1)
transcribe.run(job.pk)
self.assertFalse(TranscriptionJob.objects.filter(pk=job.pk).exists())
self.assertLessEqual(len(self.fake.calls), 1) # no further piece was sent
self.assertClean()
def test_cancelled_while_waiting_is_not_started(self):
job = self.job()
job.delete()
transcribe.run(job.pk)
self.assertEqual(self.fake.calls, [])
class _Holder:
"""Lets a test swap the audio the fake server sends."""
data = None
audio_holder = _Holder()
class QueueTests(PipelineBase):
"""The runner and its queue. The work itself (_transcribe) is replaced by a stand-in the test
holds back, so only the runner thread writes to the database while the test looks at it."""
def setUp(self):
super().setUp()
self.gate = threading.Event()
self.started = []
patcher = patch.object(transcribe, '_resumed', True)
patcher.start()
self.addCleanup(patcher.stop)
work = patch.object(transcribe, '_transcribe', side_effect=self.work)
work.start()
self.addCleanup(work.stop)
self.addCleanup(self.gate.set)
self.addCleanup(lambda: wait_for(lambda: transcribe._runner is None))
def work(self, job_id):
self.started.append(job_id)
self.gate.wait(30)
return {'segments': [{'start': 0, 'end': 1, 'speaker': '', 'text': 'x'}], 'source': {'type': 'whisper'}}
def test_one_job_at_a_time_oldest_first(self):
first, second, third = self.job(), self.job(), self.job()
transcribe.kick()
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
transcribe.kick() # a second runner doesn't start
time.sleep(0.3)
self.assertEqual(self.started, [first.pk])
self.assertEqual([self.reload(j).state for j in (first, second, third)], ['downloading', 'queued', 'queued'])
self.gate.set()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [first.pk, second.pk, third.pk])
jobs = [self.reload(j) for j in (first, second, third)]
self.assertEqual([j.state for j in jobs], ['done'] * 3)
self.assertEqual(sorted(j.finished_at for j in jobs), [j.finished_at for j in jobs])
def test_a_job_queued_while_the_runner_works_is_taken_too(self):
first = self.job()
transcribe.kick()
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
second = self.job()
transcribe.kick()
self.gate.set()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [first.pk, second.pk])
def test_a_cancelled_waiting_job_is_skipped(self):
first, second, third = self.job(), self.job(), self.job()
transcribe.kick()
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
second.delete()
self.gate.set()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [first.pk, third.pk])
def test_a_crashing_job_does_not_stop_the_queue(self):
first, second = self.job(), self.job()
self.gate.set()
calls = []
def work(job_id):
calls.append(job_id)
if job_id == first.pk:
raise RuntimeError('boom')
return self.work(job_id)
with patch.object(transcribe, '_transcribe', side_effect=work):
transcribe.kick()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(calls, [first.pk, second.pk])
self.assertEqual((self.reload(first).state, self.reload(first).error), ('error', 'RuntimeError: boom'))
self.assertEqual(self.reload(second).state, 'done')
def test_stuck_jobs_start_again_after_a_restart(self):
self.gate.set()
job = self.job()
TranscriptionJob.objects.filter(pk=job.pk).update(state='transcribing', chunks_done=2, chunks_total=3)
with patch.object(transcribe, '_resumed', False):
transcribe.kick()
self.assertTrue(wait_for(lambda: transcribe._runner is None))
self.assertEqual(self.started, [job.pk])
self.assertEqual(self.reload(job).state, 'done')
def test_nothing_queued_starts_no_runner(self):
transcribe.kick()
self.assertIsNone(transcribe._runner)
# --------------------------------------------------------------------------- endpoints
class EndpointBase(TestCase):
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/f', title='Der Podcast')
self.ep = self.episode('e1')
kick = patch.object(transcribe, 'kick')
self.kick = kick.start()
self.addCleanup(kick.stop)
def episode(self, guid, **kw):
kw.setdefault('audio_url', f'https://example.org/{guid}.mp3')
return PodcastEpisode.objects.create(feed=self.feed, guid=guid, title=f'Folge {guid}', duration_seconds=600, **kw)
def post(self, url, body=None, **extra):
return self.client.post(url, json.dumps(body or {}), content_type='application/json', **extra)
def done_job(self, ep, **kw):
kw.setdefault('data', {'segments': [{'start': 0, 'end': 4, 'speaker': '', 'text': 'Hallo.'}],
'source': {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'}})
return TranscriptionJob.objects.create(episode=ep, state='done', finished_at=timezone.now(), **kw)
class TranscribeEndpointTests(EndpointBase):
def url(self, ep=None):
return f'/podcasts/episodes/{(ep or self.ep).id}/transcribe/'
def test_requires_login_and_ownership(self):
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(self.url()).status_code, 404)
self.assertEqual(self.post(self.url()).status_code, 404)
self.client.logout()
self.assertEqual(self.client.get(self.url()).status_code, 401)
def test_none_before_a_job(self):
self.assertEqual(self.client.get(self.url()).json(), {
'state': 'none', 'chunks_done': 0, 'chunks_total': 0, 'error': '', 'want_article': False,
'started_at': None, 'finished_at': None})
@patch('podcasts.transcription._too_large', return_value=False)
def test_post_queues_a_job_and_starts_the_runner(self, _):
r = self.post(self.url())
self.assertEqual(r.status_code, 200)
self.assertEqual((r.json()['state'], r.json()['want_article']), ('queued', False))
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).state, 'queued')
self.kick.assert_called()
@patch('podcasts.transcription._too_large', return_value=False)
def test_post_again_only_raises_the_article_wish(self, _):
self.post(self.url())
first = TranscriptionJob.objects.get(episode=self.ep).pk
r = self.post(self.url(), {'article': True})
self.assertTrue(r.json()['want_article'])
r = self.post(self.url(), {'article': False}) # never lowers it
self.assertTrue(r.json()['want_article'])
self.assertEqual(TranscriptionJob.objects.filter(episode=self.ep).count(), 1)
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).pk, first)
@patch('podcasts.transcription._too_large', return_value=False)
def test_post_with_article_true_sets_the_wish(self, _):
self.assertTrue(self.post(self.url(), {'article': True}).json()['want_article'])
def test_youtube_is_refused(self):
ep = self.episode('yt', audio_url=youtube.WATCH + 'abc123')
r = self.post(self.url(ep))
self.assertEqual((r.status_code, r.json()), (400, {'error': 'youtube'}))
self.assertFalse(TranscriptionJob.objects.exists())
@patch('podcasts.transcription._too_large', return_value=True)
def test_too_large_is_refused(self, _):
r = self.post(self.url())
self.assertEqual((r.status_code, r.json()), (400, {'error': 'too large'}))
self.assertFalse(TranscriptionJob.objects.exists())
def test_the_size_check_reads_the_content_length(self):
from . import silence
resp = type('R', (), {'headers': {'Content-Length': str(silence.MAX_BYTES + 1)}})()
with patch('podcasts.transcription.requests.head', return_value=resp):
self.assertTrue(transcription._too_large('https://example.org/x.mp3'))
resp.headers = {'Content-Length': '1000'}
with patch('podcasts.transcription.requests.head', return_value=resp):
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
with patch('podcasts.transcription.requests.head', side_effect=transcription.requests.ConnectionError):
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
def test_a_whisper_transcript_is_done_already(self):
self.done_job(self.ep)
r = self.post(self.url(), {'article': True})
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
self.kick.assert_not_called()
self.assertTrue(TranscriptionJob.objects.get(episode=self.ep).want_article)
def test_a_feed_transcript_is_done_already(self):
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
self.ep.save()
r = self.post(self.url(), {'article': True})
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
job = TranscriptionJob.objects.get(episode=self.ep)
self.assertIsNone(job.data)
self.kick.assert_not_called()
@patch('podcasts.transcription._too_large', return_value=False)
def test_after_an_error_post_starts_again_at_the_back(self, _):
old = TranscriptionJob.objects.create(episode=self.ep, state='error', error='Whisper: HTTP 500')
r = self.post(self.url(), {'article': True})
self.assertEqual((r.json()['state'], r.json()['error']), ('queued', ''))
new = TranscriptionJob.objects.get(episode=self.ep)
self.assertGreater(new.pk, old.pk)
@patch('podcasts.transcription._too_large', return_value=False)
def test_delete_cancels_queued_and_running_but_not_a_finished_one(self, _):
self.post(self.url())
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
self.assertFalse(TranscriptionJob.objects.exists())
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=5, chunks_done=2)
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
self.assertFalse(TranscriptionJob.objects.exists())
self.done_job(self.ep)
self.assertEqual(self.client.delete(self.url()).json()['state'], 'done')
self.assertTrue(TranscriptionJob.objects.exists())
def test_get_shows_progress(self):
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=38, chunks_done=7,
started_at=timezone.now())
d = self.client.get(self.url()).json()
self.assertEqual((d['state'], d['chunks_done'], d['chunks_total']), ('transcribing', 7, 38))
self.assertIsNotNone(d['started_at'])
self.kick.assert_called() # a restart's first request starts the queue again
class TranscriptTests(EndpointBase):
def transcript(self, ep=None):
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/transcript/')
def detail(self, ep=None):
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/').json()
def test_404_as_before_without_any(self):
self.assertEqual((self.transcript().status_code, self.transcript().json()), (404, {'error': 'no transcript'}))
d = self.detail()
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription'], d['article_item_id']),
(False, None, None, None))
def test_falls_back_to_the_whisper_transcript(self):
job = self.done_job(self.ep)
r = self.transcript()
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), job.data)
d = self.detail()
self.assertEqual((d['has_transcript'], d['transcript_origin']), (True, 'whisper'))
self.assertEqual(d['transcription']['state'], 'done')
def test_a_running_job_has_no_transcript_yet(self):
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', data=None)
self.assertEqual(self.transcript().status_code, 404)
d = self.detail()
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription']['state']),
(False, None, 'transcribing'))
def feed_transcript(self, error=''):
ep = self.ep
ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
ep.save()
EpisodeTranscript.objects.create(
episode=ep, sources=['https://example.org/t.vtt'], error=error,
data=None if error else {'segments': [{'start': 0, 'end': 1, 'speaker': 'A', 'text': 'Aus dem Feed'}],
'source': {'url': 'https://example.org/t.vtt'}})
def test_the_feeds_transcript_wins(self):
self.done_job(self.ep)
self.feed_transcript()
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Aus dem Feed')
self.assertEqual(self.detail()['transcript_origin'], 'feed')
def test_an_unusable_feed_transcript_gives_way(self):
self.done_job(self.ep)
self.feed_transcript(error='invalid')
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
self.assertEqual(self.detail()['transcript_origin'], 'whisper')
def test_a_feed_transcript_that_cannot_be_loaded_now_gives_way_too(self):
self.done_job(self.ep)
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
self.ep.save()
from . import chapters
with patch('podcasts.views.transcripts_mod.load', side_effect=chapters.FetchUnavailable('x')):
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
def test_article_item_id(self):
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='Podcast-Artikel',
kind='blog')
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
published=timezone.now())
EpisodeArticle.objects.create(episode=self.ep, item=item)
self.assertEqual(self.detail()['article_item_id'], item.id)
class WishTests(EndpointBase):
PENDING = '/podcasts/transcription/pending/'
def claim(self, ep=None, method='post', **kw):
return getattr(self.client, method)(
f'/podcasts/episodes/{(ep or self.ep).id}/article/claim/', json.dumps(kw.pop('body', {})),
content_type='application/json', **kw)
def test_pending_lists_open_wishes_only(self):
wished = self.done_job(self.ep, want_article=True)
e2, e3, e4, e5 = (self.episode(g) for g in ('e2', 'e3', 'e4', 'e5'))
self.done_job(e2) # no wish
TranscriptionJob.objects.create(episode=e3, state='transcribing', want_article=True) # not done
self.done_job(e4, want_article=True, claimed_by='tablet', claimed_until=timezone.now() + timedelta(minutes=5))
job5 = self.done_job(e5, want_article=True)
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='P', kind='blog')
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
published=timezone.now())
EpisodeArticle.objects.create(episode=e5, item=item) # written already
r = self.client.get(self.PENDING).json()
self.assertEqual(r, [{'episode_id': self.ep.id, 'title': 'Folge e1', 'podcast': 'Der Podcast',
'duration_seconds': 600}])
self.assertTrue(wished.want_article and job5.want_article)
def test_pending_is_per_user_and_needs_login(self):
self.done_job(self.ep, want_article=True)
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(self.PENDING).json(), [])
self.client.logout()
self.assertEqual(self.client.get(self.PENDING).status_code, 401)
def test_an_expired_claim_is_open_again(self):
self.done_job(self.ep, want_article=True, claimed_by='tablet',
claimed_until=timezone.now() - timedelta(seconds=1))
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
def test_claim_blocks_the_other_device_until_it_expires(self):
job = self.done_job(self.ep, want_article=True)
r = self.claim(body={'device': 'phone'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['claimed_by'], 'phone')
job.refresh_from_db()
left = job.claimed_until - timezone.now()
self.assertTrue(timedelta(minutes=14) < left <= timedelta(minutes=15))
self.assertEqual(self.client.get(self.PENDING).json(), [])
r = self.claim(body={'device': 'desktop'})
self.assertEqual(r.status_code, 409)
self.assertEqual(r.json()['error'], 'claimed')
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 200) # the holder renews
TranscriptionJob.objects.filter(pk=job.pk).update(claimed_until=timezone.now() - timedelta(seconds=1))
self.assertEqual(self.claim(body={'device': 'desktop'}).status_code, 200)
job.refresh_from_db()
self.assertEqual(job.claimed_by, 'desktop')
def test_the_device_may_come_in_a_header(self):
self.done_job(self.ep, want_article=True)
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='phone').status_code, 200)
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='desktop').status_code, 409)
def test_a_device_name_is_required(self):
self.done_job(self.ep, want_article=True)
self.assertEqual(self.claim().status_code, 400)
def test_release(self):
self.done_job(self.ep, want_article=True)
self.claim(body={'device': 'phone'})
self.assertEqual(self.claim(method='delete', body={'device': 'desktop'}).status_code, 409)
self.assertEqual(self.claim(method='delete', body={'device': 'phone'}).status_code, 200)
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
def test_no_wish_no_claim(self):
self.done_job(self.ep) # done, but no article wanted
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 404)
self.assertEqual(self.claim(self.episode('e9'), body={'device': 'phone'}).status_code, 404)
class NotificationTests(EndpointBase):
def get(self, **q):
return self.client.get('/feeds/notifications/', q).json()
def test_first_time_only_the_cursor(self):
j = self.done_job(self.ep, want_article=True)
d = self.get()
self.assertEqual((d['transcripts'], d['t_last'], d['items']), ([], j.id, []))
self.assertEqual(d['last'], 0)
def test_jobs_done_after_the_cursor(self):
e2, e3 = self.episode('e2'), self.episode('e3')
a = self.done_job(self.ep)
b = self.done_job(e2, want_article=True)
TranscriptionJob.objects.create(episode=e3, state='transcribing') # not done: no notice
d = self.get(t_after=a.id)
self.assertEqual(d['transcripts'], [{'job_id': b.id, 'episode_id': e2.id, 'title': 'Folge e2',
'podcast': 'Der Podcast', 'want_article': True}])
self.assertEqual(d['t_last'], b.id)
self.assertEqual(self.get(t_after=b.id)['transcripts'], [])
self.assertEqual([t['job_id'] for t in self.get(t_after=0)['transcripts']], [a.id, b.id])
def test_only_the_users_own_and_not_feed_only_jobs(self):
other = User.objects.create_user(username='bob', password='pw12345678')
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/o', title='O')
ep = PodcastEpisode.objects.create(feed=feed, guid='o', title='O', audio_url='https://example.org/o.mp3')
self.done_job(ep)
self.done_job(self.episode('e2'), data=None) # only the wish for an article from a feed transcript
d = self.get(t_after=0)
self.assertEqual((d['transcripts'], d['t_last']), ([], 0))
def test_items_still_work_beside_it(self):
d = self.get(after=0)
self.assertEqual((d['items'], d['last'], d['transcripts'], d['t_last']), ([], 0, [], 0))

View file

@ -0,0 +1,153 @@
import io
import json
import wave
from unittest.mock import MagicMock, patch
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from feeds import logins
from . import chapters, transcription
from .models import TranscriptionSettings
SETTINGS = '/podcasts/transcription/settings/'
TEST = '/podcasts/transcription/test/'
def fake_response(status=200):
r = MagicMock()
r.status_code = status
r.headers = {}
r.raw = None # nothing to introspect for the peer check
return r
class SettingsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def post(self, data, url=SETTINGS):
return self.client.post(url, json.dumps(data), content_type='application/json')
def test_requires_login(self):
self.client.logout()
self.assertEqual(self.client.get(SETTINGS).status_code, 401)
self.assertEqual(self.post({}, TEST).status_code, 401)
def test_defaults(self):
self.assertEqual(self.client.get(SETTINGS).json(), {
'engine': 'builtin', 'url': '', 'model': '', 'language': '', 'parallel': 3,
'has_token': False, 'builtin_available': False})
self.assertFalse(TranscriptionSettings.objects.exists())
@override_settings(WHISPER_URL='http://whisper:8000/v1')
def test_builtin_available_follows_the_environment_setting(self):
self.assertTrue(self.client.get(SETTINGS).json()['builtin_available'])
def test_roundtrip(self):
r = self.post({'engine': 'custom', 'url': 'https://whisper.example.org/v1/', 'model': 'large-v3',
'language': 'de', 'parallel': 5, 'token': 'sk-secret'})
self.assertEqual(r.status_code, 200)
want = {'engine': 'custom', 'url': 'https://whisper.example.org/v1', 'model': 'large-v3',
'language': 'de', 'parallel': 5, 'has_token': True, 'builtin_available': False}
self.assertEqual(r.json(), want)
self.assertEqual(self.client.get(SETTINGS).json(), want)
def test_token_is_write_only_and_stored_encrypted(self):
self.post({'engine': 'custom', 'url': 'https://w.example.org/v1', 'token': 'sk-secret'})
conf = TranscriptionSettings.objects.get(user=self.user)
self.assertTrue(conf.token_encrypted)
self.assertNotIn('sk-secret', conf.token_encrypted)
self.assertEqual(transcription.token_of(conf), 'sk-secret')
for body in (self.client.get(SETTINGS).content, self.post({'model': 'm'}).content):
self.assertNotIn(b'sk-secret', body)
self.assertNotIn(conf.token_encrypted.encode(), body)
# Not sending a token keeps it; an empty one removes it.
conf.refresh_from_db()
self.assertTrue(conf.token_encrypted)
self.assertFalse(self.post({'token': ''}).json()['has_token'])
conf.refresh_from_db()
self.assertEqual(conf.token_encrypted, '')
def test_validation(self):
bad = [
{'engine': 'cloud'}, {'parallel': 0}, {'parallel': 9}, {'parallel': '3'}, {'parallel': True},
{'url': 'ftp://x.org/v1'}, {'url': 'javascript:alert(1)'}, {'url': 'whisper.local/v1'},
{'language': 'Deutsch'}, {'model': 5}, {'token': 5},
{'engine': 'custom'}, # a custom instance needs a url
]
for body in bad:
self.assertEqual(self.post(body).status_code, 400, body)
self.assertEqual(self.client.post(SETTINGS, 'nope', content_type='application/json').status_code, 400)
self.assertFalse(TranscriptionSettings.objects.exists())
for body in ({'parallel': 1}, {'parallel': 8}, {'language': ''}, {'language': 'pt-BR'}):
self.assertEqual(self.post(body).status_code, 200, body)
def test_settings_are_per_user(self):
self.post({'parallel': 7})
other = User.objects.create_user(username='bob', password='pw12345678')
self.client.force_login(other)
self.assertEqual(self.client.get(SETTINGS).json()['parallel'], 3)
class TestEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
def configure(self, **kw):
TranscriptionSettings.objects.update_or_create(user=self.user, defaults=kw)
def test_custom_sends_a_second_of_silence(self):
self.configure(engine='custom', url='https://whisper.example.org/v1', model='large-v3', language='de',
token_encrypted=logins.encrypt('sk-secret'))
calls = []
def post(url, **kw):
calls.append((url, kw))
return fake_response()
with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.post', post):
r = self.client.post(TEST)
self.assertEqual(r.json(), {'ok': True, 'model': 'large-v3', 'error': ''})
url, kw = calls[0]
self.assertEqual(url, 'https://whisper.example.org/v1/audio/transcriptions')
self.assertEqual(kw['headers']['Authorization'], 'Bearer sk-secret')
self.assertEqual(kw['data']['model'], 'large-v3')
self.assertEqual(kw['data']['language'], 'de')
name, wav, mime = kw['files']['file']
self.assertEqual(mime, 'audio/wav')
with wave.open(io.BytesIO(wav)) as w:
self.assertEqual((w.getframerate(), w.getnchannels(), w.getnframes()), (16000, 1, 16000))
def test_custom_private_address_is_refused(self):
self.configure(engine='custom', url='http://127.0.0.1:9000/v1')
with patch('podcasts.chapters.requests.post') as post:
r = self.client.post(TEST)
self.assertFalse(r.json()['ok'])
self.assertTrue(r.json()['error'])
post.assert_not_called()
def test_custom_http_error_is_reported(self):
self.configure(engine='custom', url='https://whisper.example.org/v1')
with patch('podcasts.chapters.assert_safe_url'), \
patch('podcasts.chapters.requests.post', return_value=fake_response(401)):
r = self.client.post(TEST)
self.assertEqual((r.json()['ok'], r.json()['error']), (False, 'HTTP 401'))
@override_settings(WHISPER_URL='http://whisper:8000/v1')
def test_builtin_is_exempt_from_the_address_check(self):
self.configure(engine='builtin')
with patch('podcasts.transcription.requests.post', return_value=fake_response()) as post, \
patch('podcasts.chapters.assert_safe_url', side_effect=chapters.WebDAVInputError('private')):
r = self.client.post(TEST)
self.assertTrue(r.json()['ok'])
self.assertEqual(post.call_args.args[0], 'http://whisper:8000/v1/audio/transcriptions')
self.assertNotIn('Authorization', post.call_args.kwargs['headers'])
def test_builtin_without_instance(self):
r = self.client.post(TEST)
self.assertFalse(r.json()['ok'])
self.assertTrue(r.json()['error'])

View file

@ -837,7 +837,7 @@ class EpisodeListTests(TestCase):
a2 = dict(eps[self.a2.id])
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
self.assertEqual(a2, {
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'link': '', 'duration_seconds': 0,
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False,

378
podcasts/transcribe.py Normal file
View file

@ -0,0 +1,378 @@
"""Transcribe an episode with a Whisper instance (docs/transcription.md, "Transcribing").
The server downloads the episode (as silence.py does, with the same size limit), decodes it
once to 16 kHz mono, cuts it into pieces of about eight minutes where there is a pause (so no
word is cut), sends the pieces in parallel to the instance's OpenAI-compatible
`/audio/transcriptions` and joins the answers into one transcript, which is kept on the job
(TranscriptionJob.data).
A job runs in a daemon thread, one at a time per server; the others wait as `queued` and are
taken oldest first (by id). A job found `downloading` or `transcribing` when the process has
just started (a restart killed it) is queued again. Cancelling deletes the job's row: the
worker notices when it next writes progress and stops.
"""
import logging
import os
import subprocess
import tempfile
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
import requests
from django.conf import settings
from django.db import close_old_connections
from django.utils import timezone
from . import chapters, silence, transcripts
from .models import TranscriptionJob, TranscriptionSettings
from .transcription import DEFAULT_MODEL, for_user, token_of
log = logging.getLogger(__name__)
CHUNK_SECONDS = 8 * 60 # about this long a piece
CUT_WINDOW = 30 # a cut moves to the longest pause within this many seconds
MIN_TAIL = 60 # a last piece shorter than this is joined to the one before
RATE = 16000 # Hz, mono, 16-bit
WINDOW = silence.WINDOW
RETRIES = 3
RETRY_PAUSES = (3, 10, 30) # seconds before each retry
UPLOAD_TIMEOUT = (10, 3600) # a CPU instance may need many minutes for a piece
DECODE_TIMEOUT = 3600
OPUS_BITRATE = '32k'
MAX_PROMPT = 500 # characters
ALIVE_CHECK = 5 # seconds between looks whether the job was cancelled
_lock = threading.Lock()
_runner = None # the thread working through the queue, or None
_resumed = False # whether jobs left over from before a restart were queued again
class Cancelled(Exception):
"""The job's row is gone: the user cancelled it."""
class Failed(Exception):
"""The job can't succeed; the message is shown to the user."""
class Permanent(Exception):
"""A request the instance refused for good (a client error): not worth a retry."""
# ---------------------------------------------------------------------------
# Queue
# ---------------------------------------------------------------------------
def kick():
"""Make sure queued jobs are being worked on. Called by the endpoints, so after a restart
the first request brings back what was running."""
global _runner, _resumed
with _lock:
if not _resumed:
_resumed = True
TranscriptionJob.objects.filter(
state__in=[TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING],
).update(state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0)
if _runner is not None:
return
if not TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED).exists():
return
_runner = threading.Thread(target=_loop, name='transcribe', daemon=True)
_runner.start()
def _next_job():
global _runner
with _lock:
job_id = (TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED)
.order_by('id').values_list('id', flat=True).first())
if job_id is None:
_runner = None # decided under the lock, so a job queued right now starts a new runner
return job_id
def _loop():
global _runner
try:
while True:
close_old_connections()
job_id = _next_job()
if job_id is None:
return
try:
run(job_id)
except Exception: # run() reports its own failures; this keeps the queue going
log.exception('transcription job %s crashed', job_id)
except BaseException:
with _lock:
_runner = None
raise
finally:
close_old_connections()
def run(job_id):
"""Work one job through to `done` or `error`, in this thread."""
started = TranscriptionJob.objects.filter(pk=job_id, state=TranscriptionJob.QUEUED).update(
state=TranscriptionJob.DOWNLOADING, started_at=timezone.now(), finished_at=None,
error='', chunks_done=0, chunks_total=0)
if not started:
return # cancelled while it waited
try:
data = _transcribe(job_id)
_write(job_id, state=TranscriptionJob.DONE, data=data, error='', finished_at=timezone.now())
except Cancelled:
log.info('transcription job %s cancelled', job_id)
except Failed as e:
_fail(job_id, str(e))
except Exception as e:
log.exception('transcription job %s failed', job_id)
_fail(job_id, f'{type(e).__name__}: {e}')
def _fail(job_id, message):
TranscriptionJob.objects.filter(pk=job_id).update(
state=TranscriptionJob.ERROR, error=(message or 'failed')[:500], finished_at=timezone.now())
def _write(job_id, **fields):
"""Update the job; Cancelled when its row is gone."""
if not TranscriptionJob.objects.filter(pk=job_id).update(**fields):
raise Cancelled()
def _alive(job_id):
if not TranscriptionJob.objects.filter(pk=job_id).exists():
raise Cancelled()
# ---------------------------------------------------------------------------
# The work
# ---------------------------------------------------------------------------
def _target(conf):
"""(url, headers, builtin) of the instance the settings point at."""
builtin = conf.engine == TranscriptionSettings.BUILTIN
base = settings.WHISPER_URL if builtin else conf.url
if not base:
raise Failed('Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen')
headers = {'User-Agent': 'diora transcription'}
if not builtin and conf.token_encrypted:
headers['Authorization'] = 'Bearer ' + token_of(conf)
return base.rstrip('/') + '/audio/transcriptions', headers, builtin
def _prompt(episode):
parts = [episode.feed.title, episode.title, episode.feed.author]
return '. '.join(p.strip() for p in parts if p and p.strip())[:MAX_PROMPT]
def _transcribe(job_id):
job = TranscriptionJob.objects.select_related('episode__feed').get(pk=job_id)
episode = job.episode
conf = for_user(episode.feed.user)
url, headers, builtin = _target(conf)
options = {'model': conf.model or DEFAULT_MODEL, 'response_format': 'verbose_json'}
if conf.language:
options['language'] = conf.language
prompt = _prompt(episode)
if prompt:
options['prompt'] = prompt
with tempfile.TemporaryDirectory(prefix='diora-transcribe-') as tmp:
source = os.path.join(tmp, 'episode.audio')
_download(episode.audio_url, source, job_id)
pcm = os.path.join(tmp, 'episode.pcm')
_decode(source, pcm)
os.remove(source)
total = os.path.getsize(pcm) // 2 / RATE
if total <= 0:
raise Failed('Kein Ton in der Datei')
pieces = plan(total, pauses_of(pcm))
_write(job_id, state=TranscriptionJob.TRANSCRIBING, chunks_total=len(pieces), chunks_done=0)
results = {}
stop = threading.Event() # set when the job ends early: no piece starts after that
suffix, mime = ('.flac', 'audio/flac') if builtin else ('.ogg', 'audio/ogg')
def work(index):
# Only the HTTP side runs here; the database is touched by this thread alone.
if stop.is_set():
return index, None
start, end = pieces[index]
path = _encode(pcm, start, end, tmp, index, opus=not builtin)
try:
with open(path, 'rb') as f:
body = f.read()
finally:
os.remove(path)
return index, _send(url, headers, builtin, f'piece-{index}{suffix}', mime, body, options, stop)
pool = ThreadPoolExecutor(max_workers=max(1, min(conf.parallel, 8)))
try:
pending = {pool.submit(work, i) for i in range(len(pieces))}
while pending:
finished, pending = wait(pending, timeout=ALIVE_CHECK, return_when=FIRST_COMPLETED)
_alive(job_id) # also while a long piece is out
for f in finished:
index, answer = f.result() # the first failure ends the job
results[index] = answer
if finished:
_write(job_id, chunks_done=len(results))
finally:
stop.set()
pool.shutdown(wait=True, cancel_futures=True)
return build(pieces, [results[i] for i in range(len(pieces))], options['model'], conf.language)
def _download(url, path, job_id):
size = 0
with open(path, 'wb') as out, requests.get(
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora transcription'}) as r:
if r.status_code >= 400:
raise Failed(f'Download: HTTP {r.status_code}')
if int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES:
raise Failed('too large')
for n, chunk in enumerate(r.iter_content(1 << 20)):
size += len(chunk)
if size > silence.MAX_BYTES:
raise Failed('too large')
out.write(chunk)
if n % 8 == 0:
_alive(job_id)
if size == 0:
raise Failed('Download: leere Datei')
def _run_ffmpeg(args, input=None, timeout=DECODE_TIMEOUT):
import imageio_ffmpeg
proc = subprocess.run(
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-loglevel', 'error', *args],
input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', 'replace').strip().splitlines()
raise Failed('ffmpeg: ' + (err[-1] if err else f'exit {proc.returncode}')[:200])
def _decode(source, pcm):
"""The audio at `source` as raw 16 kHz mono 16-bit samples in the file `pcm`."""
_run_ffmpeg(['-vn', '-sn', '-dn', '-i', source, '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-y', pcm])
def pauses_of(pcm):
"""The pauses of the decoded audio as [(middle, length)] in seconds, found with
silence.py's rules (what it cuts out of a pause is centred on the pause)."""
size = int(RATE * WINDOW) * 2
levels = []
with open(pcm, 'rb') as f:
while True:
data = f.read(size * 100000)
if not data:
break
for i in range(0, len(data) - size + 1, size):
levels.append(silence._level_db(data[i:i + size]))
return [((a + b) / 2, b - a) for a, b in silence.cuts(levels)]
def plan(total, pauses):
"""[(start, end)] in seconds covering 0..total: pieces of about CHUNK_SECONDS, each cut at
the longest pause within CUT_WINDOW of where it would fall (right there when there is none)."""
cuts = []
last = 0.0
k = 1
while k * CHUNK_SECONDS < total - MIN_TAIL:
target = k * CHUNK_SECONDS
k += 1
near = [p for p in pauses if abs(p[0] - target) <= CUT_WINDOW and p[0] > last + 1]
at = max(near, key=lambda p: p[1])[0] if near else target
if at <= last or at >= total:
continue
cuts.append(at)
last = at
edges = [0.0, *cuts, total]
return [(edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
def _encode(pcm, start, end, tmp, index, opus):
"""The piece [start, end) of the decoded audio as FLAC or Opus (ogg), in a file."""
with open(pcm, 'rb') as f:
f.seek(int(round(start * RATE)) * 2)
data = f.read(int(round((end - start) * RATE)) * 2)
out = os.path.join(tmp, f'piece-{index}.' + ('ogg' if opus else 'flac'))
codec = ['-c:a', 'libopus', '-b:a', OPUS_BITRATE, '-f', 'ogg'] if opus else ['-c:a', 'flac', '-f', 'flac']
_run_ffmpeg(['-f', 's16le', '-ar', str(RATE), '-ac', '1', '-i', 'pipe:0', *codec, '-y', out], input=data)
return out
def _post(url, headers, builtin, name, mime, body, options):
"""One request; the parsed answer. Permanent for a refusal that won't change, RuntimeError
for trouble that may pass."""
files = {'file': (name, body, mime)}
try:
if builtin:
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
r = requests.post(url, headers=headers, files=files, data=options, timeout=UPLOAD_TIMEOUT)
if r.status_code >= 400:
if r.status_code < 500 and r.status_code not in (408, 429):
raise Permanent(f'HTTP {r.status_code}')
raise RuntimeError(f'HTTP {r.status_code}')
else:
try:
r = chapters._open(url, headers, method='POST', timeout=UPLOAD_TIMEOUT, files=files, data=options)
except chapters.ChaptersInvalid as e:
raise Permanent(str(e))
except chapters.ChaptersUnavailable as e:
raise RuntimeError(str(e))
try:
return r.json()
finally:
r.close()
except requests.RequestException as e:
raise RuntimeError(e.__class__.__name__)
except ValueError:
raise RuntimeError('Antwort ist kein JSON')
def _send(url, headers, builtin, name, mime, body, options, stop):
"""A piece to the instance, retried up to RETRIES times with a growing pause."""
for attempt in range(RETRIES + 1):
if stop.is_set():
raise Cancelled()
try:
return _post(url, headers, builtin, name, mime, body, options)
except Permanent as e:
raise Failed(f'Whisper: {e}')
except RuntimeError as e:
if attempt >= RETRIES:
raise Failed(f'Whisper: {e}')
log.info('whisper piece %s failed (%s), retrying', name, e)
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
def build(pieces, answers, model, language):
"""{segments, source} from the pieces' places and the instance's verbose_json answers."""
fragments = []
detected = ''
for (start, end), answer in zip(pieces, answers):
if not isinstance(answer, dict):
continue
detected = detected or (answer.get('language') or '')
segments = answer.get('segments')
if not isinstance(segments, list): # a plain {"text": …} answer: one segment for the piece
text = (answer.get('text') or '').strip()
segments = [{'start': 0, 'end': end - start, 'text': text}] if text else []
for s in segments:
if not isinstance(s, dict):
continue
text = ' '.join(str(s.get('text') or '').split())
try:
a, b = float(s.get('start', 0)), float(s.get('end', 0))
except (TypeError, ValueError):
continue
if text:
fragments.append((start + a, start + b, '', text))
merged = transcripts.merge(fragments)
if not merged:
raise Failed('Kein Text erkannt')
return {'segments': merged, 'source': {'type': 'whisper', 'model': model, 'language': language or detected}}

297
podcasts/transcription.py Normal file
View file

@ -0,0 +1,297 @@
"""Transcription settings and the test of the configured Whisper instance
(docs/transcription.md). The job itself is podcasts/transcribe.py."""
import io
import json
import re
import wave
from urllib.parse import urlsplit
import requests
from django.conf import settings
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 feeds import logins
from . import chapters
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob, TranscriptionSettings
DEFAULT_MODEL = 'whisper-1'
TEST_TIMEOUT = 60 # a cold CPU instance may load its model first
LANGUAGE_RE = re.compile(r'^[a-z]{2,3}(-[A-Za-z]{2,4})?$')
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 (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else None
def for_user(user):
"""The user's settings (unsaved defaults when there are none)."""
return TranscriptionSettings.objects.filter(user=user).first() or TranscriptionSettings(user=user)
def token_of(conf):
"""The plain API token ('' when there is none or it can't be read)."""
return (logins.decrypt(conf.token_encrypted) or '') if conf.token_encrypted else ''
def _data(conf):
return {
'engine': conf.engine, 'url': conf.url, 'model': conf.model, 'language': conf.language,
'parallel': conf.parallel, 'has_token': bool(conf.token_encrypted),
'builtin_available': bool(settings.WHISPER_URL),
}
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def transcription_settings(request):
if (err := _auth(request)):
return err
conf = for_user(request.user)
if request.method == 'GET':
return JsonResponse(_data(conf))
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
if 'engine' in body:
if body['engine'] not in dict(TranscriptionSettings.ENGINES):
return JsonResponse({'error': 'engine must be builtin or custom'}, status=400)
conf.engine = body['engine']
if 'url' in body:
url = body['url']
if not isinstance(url, str):
return JsonResponse({'error': 'url must be text'}, status=400)
url = url.strip().rstrip('/')
if url and (urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname):
return JsonResponse({'error': 'url must start with http:// or https://'}, status=400)
if len(url) > 500:
return JsonResponse({'error': 'url too long'}, status=400)
conf.url = url
if 'model' in body:
if not isinstance(body['model'], str) or len(body['model'].strip()) > 100:
return JsonResponse({'error': 'invalid model'}, status=400)
conf.model = body['model'].strip()
if 'language' in body:
lang = body['language']
if not isinstance(lang, str) or (lang.strip() and not LANGUAGE_RE.match(lang.strip())):
return JsonResponse({'error': 'language must be empty or an ISO code'}, status=400)
conf.language = lang.strip()
if 'parallel' in body:
n = body['parallel']
if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= 8:
return JsonResponse({'error': 'parallel must be 1 to 8'}, status=400)
conf.parallel = n
if 'token' in body:
if not isinstance(body['token'], str):
return JsonResponse({'error': 'token must be text'}, status=400)
conf.token_encrypted = logins.encrypt(body['token']) if body['token'] else ''
if conf.engine == TranscriptionSettings.CUSTOM and not conf.url:
return JsonResponse({'error': 'a custom instance needs a url'}, status=400)
conf.save()
return JsonResponse(_data(conf))
def silent_wav(seconds=1, rate=16000):
"""`seconds` of 16-bit mono silence as a WAV file, made in memory."""
buf = io.BytesIO()
with wave.open(buf, 'wb') as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b'\0\0' * rate * seconds)
return buf.getvalue()
@csrf_exempt
@require_http_methods(['POST'])
def transcription_test(request):
"""One second of silence to the configured instance: {ok, model, error}."""
if (err := _auth(request)):
return err
conf = for_user(request.user)
builtin = conf.engine == TranscriptionSettings.BUILTIN
base = settings.WHISPER_URL if builtin else conf.url
model = conf.model or DEFAULT_MODEL
if not base:
error = 'Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen'
return JsonResponse({'ok': False, 'model': model, 'error': error})
url = base.rstrip('/') + '/audio/transcriptions'
headers = {'User-Agent': 'diora transcription test'}
if not builtin and conf.token_encrypted:
headers['Authorization'] = 'Bearer ' + token_of(conf)
files = {'file': ('silence.wav', silent_wav(), 'audio/wav')}
data = {'model': model, 'response_format': 'json'}
if conf.language:
data['language'] = conf.language
try:
if builtin:
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
r = requests.post(url, headers=headers, files=files, data=data, timeout=TEST_TIMEOUT)
if r.status_code >= 400:
raise chapters.ChaptersInvalid(f'HTTP {r.status_code}')
else:
r = chapters._open(url, headers, method='POST', timeout=TEST_TIMEOUT, files=files, data=data)
r.close()
except (chapters.ChaptersInvalid, chapters.ChaptersUnavailable) as e:
return JsonResponse({'ok': False, 'model': model, 'error': str(e)})
except requests.RequestException as e:
return JsonResponse({'ok': False, 'model': model, 'error': e.__class__.__name__})
return JsonResponse({'ok': True, 'model': model, 'error': ''})
# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------
CLAIM_MINUTES = 15
ACTIVE = (TranscriptionJob.QUEUED, TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING)
def job_state(job):
"""The job as the API shows it; state `none` when there is no job."""
if job is None:
return {'state': TranscriptionJob.NONE, 'chunks_done': 0, 'chunks_total': 0, 'error': '',
'want_article': False, 'started_at': None, 'finished_at': None}
return {
'state': job.state, 'chunks_done': job.chunks_done, 'chunks_total': job.chunks_total,
'error': job.error, 'want_article': job.want_article,
'started_at': job.started_at.isoformat() if job.started_at else None,
'finished_at': job.finished_at.isoformat() if job.finished_at else None,
}
def job_of(episode):
return TranscriptionJob.objects.filter(episode=episode).first()
def _too_large(url):
"""Whether the server says the file at `url` is over the size limit (a failed look doesn't count)."""
from . import silence
try:
r = requests.head(url, timeout=5, allow_redirects=True, headers={'User-Agent': 'diora transcription'})
return int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES
except (requests.RequestException, ValueError):
return False
@csrf_exempt
@require_http_methods(['GET', 'POST', 'DELETE'])
def episode_transcribe(request, pk):
"""POST starts the episode's transcription (or just raises `want_article` on one that exists),
GET tells how far it is, DELETE cancels a queued or running one."""
from . import transcribe, views
from .youtube import is_video
if (err := _auth(request)):
return err
ep = PodcastEpisode.objects.select_related('feed').filter(pk=pk, feed__user=request.user).first()
if ep is None:
return JsonResponse({'error': 'not found'}, status=404)
job = job_of(ep)
if request.method == 'GET':
transcribe.kick() # after a restart the first request brings the work back
return JsonResponse(job_state(job))
if request.method == 'DELETE':
if job is not None and job.state != TranscriptionJob.DONE:
job.delete() # a running worker notices its row is gone and stops
job = None
return JsonResponse(job_state(job))
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
article = body.get('article') is True
if is_video(ep.audio_url):
return JsonResponse({'error': 'youtube'}, status=400)
has_feed = views._has_transcript(ep)
if job is not None and (job.state in ACTIVE or (job.state == TranscriptionJob.DONE and (job.data or has_feed))):
if article and not job.want_article:
job.want_article = True
job.save(update_fields=['want_article'])
return JsonResponse(job_state(job))
if has_feed: # the feed's own transcript is there: only the article is wanted
if job is None:
job = TranscriptionJob(episode=ep)
job.state, job.want_article, job.finished_at = TranscriptionJob.DONE, article or job.want_article, timezone.now()
job.save()
return JsonResponse(job_state(job))
if not ep.audio_url:
return JsonResponse({'error': 'no audio'}, status=400)
if _too_large(ep.audio_url):
return JsonResponse({'error': 'too large'}, status=400)
if job is not None:
job.delete() # a failed or hollow earlier run: the new one starts at the back of the queue
job = TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, want_article=article)
transcribe.kick()
return JsonResponse(job_state(job))
def _wishes(user):
"""The user's jobs whose transcript is there and whose article is wanted but not written."""
return (TranscriptionJob.objects
.filter(episode__feed__user=user, state=TranscriptionJob.DONE, want_article=True)
.exclude(episode__in=EpisodeArticle.objects.values('episode')))
@csrf_exempt
@require_http_methods(['GET'])
def transcription_pending(request):
"""The open wishes for an article that no device holds right now."""
from . import transcribe
if (err := _auth(request)):
return err
transcribe.kick()
jobs = (_wishes(request.user).exclude(claimed_until__gt=timezone.now())
.select_related('episode__feed').order_by('id'))
return JsonResponse([{
'episode_id': j.episode_id, 'title': j.episode.title, 'podcast': j.episode.feed.title,
'duration_seconds': j.episode.duration_seconds,
} for j in jobs], safe=False)
@csrf_exempt
@require_http_methods(['POST', 'DELETE'])
def article_claim(request, pk):
"""Reserve the wish for an article for the calling device for CLAIM_MINUTES (POST), or give
it up (DELETE). The device names itself with `device` in the JSON body or the
X-Diora-Device header. 409 when another device holds it."""
from datetime import timedelta
if (err := _auth(request)):
return err
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
device = body.get('device') or request.META.get('HTTP_X_DIORA_DEVICE') or ''
if not isinstance(device, str) or not device.strip():
return JsonResponse({'error': 'device missing'}, status=400)
device = device.strip()[:60]
job = _wishes(request.user).filter(episode_id=pk).first()
if job is None:
return JsonResponse({'error': 'no open wish'}, status=404)
now = timezone.now()
if job.claimed_until and job.claimed_until > now and job.claimed_by != device:
return JsonResponse({'error': 'claimed', 'claimed_until': job.claimed_until.isoformat()}, status=409)
if request.method == 'DELETE':
job.claimed_by, job.claimed_until = '', None
else:
job.claimed_by, job.claimed_until = device, now + timedelta(minutes=CLAIM_MINUTES)
job.save(update_fields=['claimed_by', 'claimed_until'])
return JsonResponse({'claimed_by': job.claimed_by,
'claimed_until': job.claimed_until.isoformat() if job.claimed_until else None})

View file

@ -1,5 +1,5 @@
from django.urls import path
from . import audiobooks, discover, folders, radio, views
from . import article, audiobooks, discover, folders, radio, transcription, views
urlpatterns = [
path('search/', views.podcast_search, name='podcast_search'),
@ -28,9 +28,15 @@ urlpatterns = [
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes/<int:pk>/article/', article.episode_article, name='podcast_episode_article'),
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
path('transcription/settings/', transcription.transcription_settings, name='podcast_transcription_settings'),
path('transcription/test/', transcription.transcription_test, name='podcast_transcription_test'),
path('transcription/pending/', transcription.transcription_pending, name='podcast_transcription_pending'),
path('episodes/<int:pk>/transcribe/', transcription.episode_transcribe, name='podcast_episode_transcribe'),
path('episodes/<int:pk>/article/claim/', transcription.article_claim, name='podcast_article_claim'),
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'),

View file

@ -29,11 +29,12 @@ from . import chapters as chapters_mod
from . import silence as silence_mod
from . import sponsor as sponsor_mod
from . import feedext
from . import transcription as transcription_mod
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,
EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
)
from .shownotes import extract_timestamps, sanitize_html
@ -231,7 +232,7 @@ def _later_data(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__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date',
@ -383,7 +384,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
return bool(changed)
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
'title', 'pub_date', 'episode_number']
@ -642,6 +643,12 @@ def _move_to_permanent_url(feed_obj, new_url):
feed_obj.rss_url = new_url
def _entry_link(entry):
"""The item's <link>, the episode's own page; '' unless it is an http(s) address."""
link = (entry.get('link') or '').strip()
return link[:1000] if re.match(r'^https?://', link, re.I) else ''
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."""
@ -713,6 +720,7 @@ def _apply_feed(feed_obj, fetched, full=False):
audio_url = _entry_media_url(entry)
if not audio_url:
continue
link = _entry_link(entry)
guid = entry.get('id') or entry.get('guid') or audio_url
guid = str(guid)[:1000]
@ -737,7 +745,7 @@ def _apply_feed(feed_obj, fetched, full=False):
known = existing.get(guid)
if known is not None:
renamed = {'title': title, 'episode_number': ep_number}
renamed = {'title': title, 'episode_number': ep_number, 'link': link}
if pub_date is not None:
renamed['pub_date'] = pub_date
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
@ -762,6 +770,7 @@ def _apply_feed(feed_obj, fetched, full=False):
'title': title,
'description': description,
'audio_url': audio_url[:1000],
'link': link,
'duration_seconds': duration_seconds,
'pub_date': pub_date,
'episode_number': ep_number,
@ -1324,7 +1333,7 @@ def feed_episodes(request, pk):
episodes = list(
feed.episodes.values(
'id', 'title', 'description', 'audio_url', 'duration_seconds',
'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
'pub_date', 'artwork_url', 'episode_number', 'season_number',
)
)
@ -1424,6 +1433,9 @@ def episode_detail(request, pk):
chapters = _load_chapters(ep)
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
job = transcription_mod.job_of(ep)
origin = _transcript_origin(ep, job)
article = EpisodeArticle.objects.filter(episode=ep).first()
return JsonResponse({
'id': ep.id,
'title': ep.title,
@ -1431,9 +1443,13 @@ def episode_detail(request, pk):
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
'chapters': chapters,
'transcripts': ep.transcripts,
'has_transcript': _has_transcript(ep),
'has_transcript': origin is not None,
'transcript_origin': origin,
'article_item_id': article.item_id if article else None,
'transcription': transcription_mod.job_state(job) if job else None,
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
'audio_url': ep.audio_url,
'link': ep.link,
'artwork_url': ep.artwork_url,
'duration_seconds': ep.duration_seconds,
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
@ -1461,6 +1477,13 @@ def _has_transcript(ep):
return not (cached and cached[0] == sources and cached[1])
def _transcript_origin(ep, job=None):
""""feed" when the feed has a usable transcript, else "whisper" when we made one, else None."""
if _has_transcript(ep):
return 'feed'
return 'whisper' if job is not None and job.state == 'done' and job.data else None
@csrf_exempt
@require_http_methods(['GET'])
def episode_silences(request, pk):
@ -1503,20 +1526,28 @@ def episode_sponsor(request, pk):
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."""
kept. When the feed has none we can use, the one Whisper made (a finished
TranscriptionJob, docs/transcription.md). 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)
def fallback(error, status=404):
job = transcription_mod.job_of(ep)
if job is not None and job.state == 'done' and job.data:
return JsonResponse(job.data)
return JsonResponse({'error': error}, status=status)
if not ep.transcripts:
return JsonResponse({'error': 'no transcript'}, status=404)
return fallback('no transcript')
sources = _transcript_sources(ep)
if not sources:
return JsonResponse({'error': 'unsupported'}, status=404)
return fallback('unsupported')
cached = EpisodeTranscript.objects.filter(episode=ep).first()
if cached is not None and cached.sources == sources:
@ -1527,7 +1558,7 @@ def episode_transcript(request, pk):
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)
return fallback('unavailable', 502)
except transcripts_mod.Unsupported as e:
log.info('transcript of episode %s unsupported: %s', ep.id, e)
data, error, final = None, 'unsupported', True
@ -1539,7 +1570,7 @@ def episode_transcript(request, pk):
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
)
if error:
return JsonResponse({'error': error}, status=404)
return fallback(error)
return JsonResponse(data)
@ -1645,7 +1676,7 @@ 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',
'id', 'title', 'audio_url', 'link', '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',
)
@ -2010,7 +2041,7 @@ def _queue_data(user):
.select_related('episode__feed')
.values(
'id', 'position',
'episode__id', 'episode__title', 'episode__audio_url',
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
'episode__duration_seconds', 'episode__artwork_url',
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
'episode__pub_date',
@ -2506,7 +2537,7 @@ def inbox(request):
def _inbox_fields(with_description=True):
fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url']
if with_description:
fields.insert(2, 'description')

View file

@ -7,7 +7,7 @@
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
// authors, folder names …). Nothing is changed without the user ticking it in review().
//
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema, effort, maxTokens, onProgress, signal}), review(…)}
// ---------------------------------------------------------------------------
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
@ -116,12 +116,17 @@ function parseAnswer(text) {
}
}
// Words in a text so far (for onProgress).
const countWords = t => { const m = String(t || '').match(/\S+/g); return m ? m.length : 0; };
// One request with a JSON answer of `schema` (structured outputs).
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
// Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed,
// it reports once at the end), signal (an AbortSignal) to give up.
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, onProgress, signal}) {
const v = await loadVault();
const key = keyOf(v);
if (!key) throw fail('Kein API-Key hinterlegt.');
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal});
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
const stream = client(key).beta.messages.stream({
model: PROVIDERS.anthropic.model,
@ -131,8 +136,18 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
system,
messages: [{role: 'user', content: prompt}],
output_config: {effort, format: {type: 'json_schema', schema}},
});
}, signal ? {signal} : undefined);
if (onProgress) {
let last = 0;
stream.on('text', (delta, snapshot) => {
const now = Date.now();
if (now - last < 400) return;
last = now;
try { onProgress(countWords(snapshot)); } catch (e) { /* the caller's display must not break the call */ }
});
}
const msg = await stream.finalMessage();
if (onProgress) { try { onProgress(countWords(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''))); } catch (e) { /* see above */ } }
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
@ -149,8 +164,8 @@ async function openRouterFetch(key, path, init) {
return r.json();
}
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
model: PROVIDERS.openrouter.model,
max_tokens: maxTokens,
reasoning: {effort},
@ -161,7 +176,9 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.');
if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
return parseAnswer((choice.message && choice.message.content) || '');
const text = (choice.message && choice.message.content) || '';
if (onProgress) { try { onProgress(countWords(text)); } catch (e) { /* the caller's display must not break the call */ } }
return parseAnswer(text);
}
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
@ -361,6 +378,8 @@ window.__dioraBack = function () {
return back ? back.apply(this, arguments) : false;
};
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
const modelName = () => PROVIDERS[providerOf(vault)].model;
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError,
close, toast, explain};
window.dispatchEvent(new Event('diora-ai'));

View file

@ -26,7 +26,8 @@
const href = a.getAttribute('href');
a.removeAttribute('href');
if (href.length < 2) return;
if (!_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
// "#t=754": a second in a podcast's article, never a note.
if (!/^#t=\d+$/.test(href) && !_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
a.dataset.footnoteRef = href.slice(1);
a.dataset.footnoteHref = href;
a.style.cursor = 'pointer';

View file

@ -720,6 +720,10 @@
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
const content = i.content || '';
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
if (i.episode_id) {
// The article of a podcast episode (docs/transcription.md): to the episode itself.
h += '<p class="fd-art-listen"><button type="button" class="btn" data-r="episode" data-id="' + i.episode_id + '">▶ Folge anhören</button></p>';
}
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
if (i.paywalled) {
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
@ -800,12 +804,72 @@
});
}
// ---- the episode of a podcast article (docs/transcription.md)
function podcastHost() {
return window.DioraPodcastHost || window.DioraNative || null;
}
function parseJson(text, fallback) {
try { return JSON.parse(text); } catch (e) { return fallback; }
}
// Play the episode from `seconds` (null: open its page, where ▶ starts it). The browser and the
// diora desktop have the host of podcasts-web.js; the apps' bridge has the same calls
// (podcastPlayEpisodeAt is the one that starts at a place; without it the episode starts at its own place).
async function playEpisode(episodeId, seconds) {
const host = podcastHost();
if (!host || !host.playerState) { alert('Der Podcast-Bereich ist hier nicht verfügbar.'); return; }
const now = parseJson(host.playerState(), {});
const ms = seconds == null ? null : Math.max(0, Math.round(seconds * 1000));
let opened = false;
if (now.kind === 'podcast' && now.episodeId === episodeId) {
if (ms != null && host.podcastSeek) host.podcastSeek(ms);
if (ms != null && !now.playing && host.podcastToggle) host.podcastToggle();
opened = ms != null;
} else {
let d;
try {
d = await api('/podcasts/episodes/' + episodeId + '/');
} catch (e) {
alert(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : 'Die Folge ist nicht mehr da.');
return;
}
const row = {id: d.id, title: d.title, audio_url: d.audio_url, duration_seconds: d.duration_seconds,
artwork_url: d.artwork_url, pub_date: d.pub_date, position_seconds: d.position_seconds,
feed__id: d.feed.id, feed__title: d.feed.title, feed__artwork_url: d.feed.artwork_url};
closeReader();
if (ms != null && host.podcastPlayEpisodeAt) {
host.podcastPlayEpisodeAt(JSON.stringify(row), now.episodeId || 0, ms);
opened = true;
} else if (ms == null && window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) {
return;
} else if (host.podcastPlayEpisode) {
host.podcastPlayEpisode(JSON.stringify(row), now.episodeId || 0);
opened = true;
}
}
closeReader();
if (opened && window.__dioraOpenPlayer) window.__dioraOpenPlayer('podcast');
}
// Clicks in the article (65-article.js hands them over): its other reports, the next article.
function onReaderClick(e) {
// A time mark in a podcast article ("▶ ab 12:34", href="#t=754"; the reader keeps it as data-internal-href).
const t = e.target.closest('a[data-internal-href^="#t="], a[href^="#t="]');
if (t && readerItem && readerItem.episode_id) {
const sec = parseInt((t.dataset.internalHref || t.getAttribute('href')).slice(3), 10);
if (sec >= 0) {
e.preventDefault();
playEpisode(readerItem.episode_id, sec);
}
return;
}
const b = e.target.closest('[data-r]');
if (!b) return;
const r = b.dataset.r;
if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
if (r === 'episode') playEpisode(parseInt(b.dataset.id, 10), null);
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
else if (r === 'story') {
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
if (other) openItem((other.lead || other.items[0]).id, other);
@ -1464,6 +1528,7 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
'.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
'.fd-artview .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
'.fd-artview .fd-art-img, .fd-artview .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
'.fd-artview .fd-art-listen { margin: 0 0 12px; }',
'.fd-artview .fd-art-body a { color: var(--accent, #e63946); }',
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',

View file

@ -0,0 +1,380 @@
// ---------------------------------------------------------------------------
// Podcast articles on the device (docs/transcription.md): the transcript of an episode goes to
// Claude with the user's own key (DioraAI, docs/ai.md) and comes back as an article that is saved
// to the episode and to the blog "Podcast-Artikel". Also the picker-up of open wishes: the server
// only remembers that an article was wanted; a device with a key writes it.
//
// An ES module like ai.js. window.DioraPodcastArticle = {write, pickUp, watch, ...}; the helpers
// are exported too (tools/tests/podcast-article.test.mjs runs them under Node).
// ---------------------------------------------------------------------------
export const MAX_TOKENS_IN = 120000; // about nine hours of speech; more stops with a message
export const GROUP_SECONDS = 30;
const SHOWNOTES_MAX = 6000; // characters of the shownotes handed to Claude
const POLL_MS = 5 * 60 * 1000;
const ai = () => globalThis.DioraAI;
export const SYSTEM = `You turn the transcript of a podcast episode into an article that reads as if the people
speaking had written it themselves instead of recording the episode.
The reader wants to decide whether this episode still has something to teach them. They do not
want a summary; they want to understand what is argued here well enough to judge it for
themselves. Write for that reader.
Perspective. Write from the speakers' standpoint and state their positions directly, as their
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
is one person speaking, write as that person argues, in the third person or impersonal voice,
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
soften what is said.
Conversations. Interviews, conversations and discussions are condensed into their core theses;
the form of question and answer disappears. Where people hold different positions, keep them
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
minutes. Questions from a host matter only where they shape the argument. When the transcript
has no speaker names, infer them from the context (introductions, the shownotes, how people
address each other); if you can't, do not invent names.
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
title. Make clear what is new, distinctive or surprising in the episode and where it restates
what is commonly said, by how you weight and order it, without grading it.
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
lead nowhere.
Form. Write in the language of the episode. \`title\`: a title for the article, not the episode
title repeated. \`summary\`: three to six sentences up front that state the central thesis and how
the episode gets there. \`sections\`: as many as the content needs (usually four to ten), each
with a heading that says something (not "Einleitung"), \`start\` = the second in the episode where
its content begins (from the [mm:ss] marks), and \`body_markdown\` in plain paragraphs; lists only
where the speakers list things themselves. Follow the order of the episode unless a different
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
roughly a tenth of the transcript, at most about 5,000 words.
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
using the metadata, and don't quote garbled passages.`;
export const SCHEMA = {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"summary",
"kind",
"speakers",
"sections"
],
"properties": {
"title": {
"type": "string"
},
"summary": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"Interview",
"Gespräch",
"Monolog",
"Diskussion"
]
},
"speakers": {
"type": "array",
"items": {
"type": "string"
}
},
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"heading",
"start",
"body_markdown"
],
"properties": {
"heading": {
"type": "string"
},
"start": {
"type": "integer"
},
"body_markdown": {
"type": "string"
}
}
}
}
}
};
// ---- the input text -------------------------------------------------------------------------
export const pad = n => String(n).padStart(2, '0');
// mm:ss (minutes keep counting past 59: "125:03"), the form of the [mm:ss] marks Claude reads.
export function mmss(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
}
// h:mm:ss from an hour on, else mm:ss: the label of the "▶ ab …" links.
export function clock(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
return s >= 3600 ? Math.floor(s / 3600) + ':' + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60) : mmss(s);
}
// One line per group of about GROUP_SECONDS: "[mm:ss] Sprecher: text", or "[mm:ss] text" without
// speakers. A new speaker always starts a new line.
export function transcriptToText(segments) {
const lines = [];
let cur = null;
for (const seg of segments || []) {
const text = String((seg && seg.text) || '').replace(/\s+/g, ' ').trim();
if (!text) continue;
const start = Number(seg.start) || 0;
const speaker = String(seg.speaker || '').trim();
if (!cur || speaker !== cur.speaker || start - cur.start >= GROUP_SECONDS) {
cur = {start, speaker, parts: []};
lines.push(cur);
}
cur.parts.push(text);
}
return lines.map(l => '[' + mmss(l.start) + '] ' + (l.speaker ? l.speaker + ': ' : '') + l.parts.join(' ')).join('\n');
}
export const estimateTokens = text => Math.ceil(String(text || '').length / 4);
// HTML (the shownotes) as plain text.
export function htmlToText(html) {
return String(html || '')
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6]|blockquote|tr)\s*>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
export function metaBlock(ep) {
const rows = [];
const feed = ep.feed || {};
if (feed.title) rows.push('Podcast: ' + feed.title);
if (ep.author || feed.author) rows.push('Autor: ' + (ep.author || feed.author));
rows.push('Folge: ' + (ep.title || ''));
if (ep.pub_date) rows.push('Datum: ' + String(ep.pub_date).slice(0, 10));
if (ep.duration_seconds) rows.push('Dauer: ' + clock(ep.duration_seconds));
let notes = htmlToText(ep.description);
if (notes.length > SHOWNOTES_MAX) notes = notes.slice(0, SHOWNOTES_MAX) + ' …';
if (notes) rows.push('Shownotes:\n' + notes);
const chapters = (ep.chapters || []).filter(c => c && c.title);
if (chapters.length) rows.push('Kapitel:\n' + chapters.map(c => '[' + mmss(c.start) + '] ' + c.title).join('\n'));
return rows.join('\n');
}
function userError(message) {
const e = new Error(message);
e.diora = true;
return e;
}
// The prompt (user turn), or an Error with a German message when it is too long.
export function buildPrompt(ep, transcript) {
const text = transcriptToText(transcript && transcript.segments);
if (!text) throw userError('Das Transkript ist leer.');
const prompt = metaBlock(ep) + '\n\nTranskript:\n' + text;
const tokens = estimateTokens(prompt + SYSTEM);
if (tokens > MAX_TOKENS_IN) {
throw userError('Dieses Transkript ist für einen Artikel zu lang (etwa ' + Math.round(tokens / 1000) +
'.000 Tokens, mehr als ' + MAX_TOKENS_IN / 1000 + '.000 gehen nicht, das sind rund neun Stunden).');
}
return prompt;
}
// ---- markdown to HTML (paragraphs, emphasis, lists, block quotes; everything else is escaped) ---
export const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Escaped first, then the emphasis marks: model output can't bring its own tags.
function inline(s) {
return esc(s)
.replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*\w])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
.replace(/(^|[^_\w])_(?=[^\s_])(.+?)(?<=[^\s_])_(?![_\w])/g, '$1<em>$2</em>');
}
export function markdownToHtml(md) {
const blocks = String(md || '').replace(/\r\n?/g, '\n').split(/\n\s*\n/);
const out = [];
for (const block of blocks) {
const lines = block.split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim());
if (!lines.length) continue;
if (lines.every(l => /^\s*>/.test(l))) {
out.push('<blockquote><p>' + inline(lines.map(l => l.replace(/^\s*>\s?/, '')).join(' ')) + '</p></blockquote>');
} else if (lines.every(l => /^\s*([-*+]|\d+[.)])\s+/.test(l))) {
const tag = /^\s*\d/.test(lines[0]) ? 'ol' : 'ul';
out.push('<' + tag + '>' + lines.map(l => '<li>' + inline(l.replace(/^\s*([-*+]|\d+[.)])\s+/, '')) + '</li>').join('') + '</' + tag + '>');
} else {
out.push('<p>' + inline(lines.map(l => l.trim()).join(' ')) + '</p>');
}
}
return out.join('\n');
}
// The article: the summary as a lead paragraph, per section a heading, the "▶ ab 12:34" link
// (feeds.js plays the item's episode from `#t=`) and the body.
export function articleHtml(answer) {
const parts = [];
if (answer.summary) parts.push(markdownToHtml(answer.summary));
for (const s of answer.sections || []) {
const t = Math.max(0, Math.floor(Number(s.start) || 0));
parts.push('<h2>' + esc(s.heading) + '</h2>');
parts.push('<p><a href="#t=' + t + '">▶ ab ' + clock(t) + '</a></p>');
parts.push(markdownToHtml(s.body_markdown));
}
return parts.filter(Boolean).join('\n');
}
// ---- talking to diora ---------------------------------------------------------------------------
function csrf() {
try {
if (typeof getCsrfToken === 'function') return getCsrfToken();
} catch (e) { /* fall through */ }
const m = typeof document !== 'undefined' && /(?:^|; )csrftoken=([^;]+)/.exec(document.cookie || '');
return m ? decodeURIComponent(m[1]) : '';
}
const send = (url, method, body) => fetch(url, {
method, headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
body: body === undefined ? undefined : JSON.stringify(body),
});
// This device's name for reserving a wish (/article/claim/), kept per browser or app.
let memDevice = '';
export function deviceId() {
try {
let id = localStorage.getItem('diora-device-id');
if (!id) { id = 'd-' + Math.random().toString(36).slice(2, 12); localStorage.setItem('diora-device-id', id); }
return id;
} catch (e) {
return memDevice || (memDevice = 'd-' + Math.random().toString(36).slice(2, 12));
}
}
export const claimWish = id => send('/podcasts/episodes/' + id + '/article/claim/', 'POST', {device: deviceId()});
export const release = id => send('/podcasts/episodes/' + id + '/article/claim/', 'DELETE', {device: deviceId()});
async function getJson(url) {
const r = await fetch(url, {cache: 'no-store'});
return {status: r.status, ok: r.ok, data: r.ok ? await r.json() : null};
}
// Write the article of an episode and save it. Resolves {item_id, title}.
// onState({phase: 'loading'|'writing'|'saving', text, words}); signal aborts.
export async function write(episodeId, {onState, signal} = {}) {
const say = (phase, text, words) => { if (onState) { try { onState({phase, text, words}); } catch (e) { /* display only */ } } };
const AI = ai();
if (!AI || !AI.available()) throw userError('Die KI-Funktionen sind auf diesem Gerät nicht verfügbar.');
say('loading', 'Lade Transkript …');
const base = '/podcasts/episodes/' + encodeURIComponent(episodeId) + '/';
const [detail, tr] = await Promise.all([getJson(base), getJson(base + 'transcript/')]);
if (!detail.ok) throw userError('Die Folge ließ sich nicht laden (' + detail.status + ').');
if (!tr.ok) throw userError(tr.status === 404 ? 'Zu dieser Folge gibt es noch kein Transkript.' : 'Das Transkript ließ sich nicht laden (' + tr.status + ').');
const prompt = buildPrompt(detail.data, tr.data);
const answer = await new Promise((resolve, reject) => {
// The key first (the settings open when there is none, as for every KI feature).
AI.withKey(() => AI.ask({
system: SYSTEM, prompt, schema: SCHEMA, effort: 'medium', maxTokens: 32000, signal,
onProgress: words => say('writing', 'Schreibe Artikel … ' + words.toLocaleString('de-DE') + ' Wörter', words),
}).then(resolve, reject));
});
if (!answer || !Array.isArray(answer.sections) || !answer.sections.length) throw userError('Die Antwort enthielt keinen Artikel.');
say('saving', 'Speichere Artikel …');
const title = answer.title || detail.data.title;
const r = await send(base + 'article/', 'POST', {
title,
summary: answer.summary || '',
body_html: articleHtml(answer),
model: AI.modelName ? AI.modelName() : 'claude-opus-5-5',
kind: answer.kind || '',
});
if (!r.ok) throw userError('Der Artikel ließ sich nicht speichern (' + r.status + ').');
const saved = await r.json();
return {item_id: saved.item_id, title};
}
// "Artikel fertig": a system notification on Android (the bridge), else a toast in the page.
export function notifyDone(title, episodeId) {
const N = globalThis.DioraNative;
try {
if (N && typeof N.notify === 'function') { N.notify('Artikel fertig', title, 'episode:' + episodeId); return; }
} catch (e) { /* fall back to the toast */ }
const AI = ai();
if (AI && AI.toast) AI.toast('Artikel fertig: ' + title);
}
let running = false;
const gaveUp = new Set(); // wishes that failed in this visit: not tried again until diora is reopened
// The open wishes (docs/transcription.md): reserve one, write it, save it. Nothing without a key.
export async function pickUp({onState} = {}) {
if (running) return {busy: true};
const AI = ai();
if (!AI || !AI.available()) return {done: 0};
running = true;
const result = {done: 0, failed: 0, skipped: 0};
try {
if (!(await AI.hasKey())) return result;
let pending;
try {
const r = await getJson('/podcasts/transcription/pending/');
if (!r.ok || !Array.isArray(r.data)) return result;
pending = r.data;
} catch (e) { return result; }
for (const wish of pending) {
if (gaveUp.has(wish.episode_id)) { result.skipped++; continue; }
let claim;
try { claim = await claimWish(wish.episode_id); } catch (e) { return result; }
if (!claim.ok) { result.skipped++; continue; } // 409: another device has it
try {
const saved = await write(wish.episode_id, {onState});
result.done++;
notifyDone(saved.title || wish.title, wish.episode_id);
} catch (e) {
result.failed++;
gaveUp.add(wish.episode_id);
try { await release(wish.episode_id); } catch (e2) { /* it runs out in 15 minutes */ }
}
}
} finally {
running = false;
}
return result;
}
// While diora is open and a job of its own runs, ask every five minutes (watch(true) when one starts,
// watch(false) when none does).
let timer = null;
export function watch(on) {
if (on && !timer) timer = setInterval(() => { pickUp(); }, POLL_MS);
else if (!on && timer) { clearInterval(timer); timer = null; }
}
export const _reset = () => { running = false; gaveUp.clear(); };
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.DioraPodcastArticle = {write, pickUp, watch, notifyDone, claim: claimWish, release, deviceId, markdownToHtml, transcriptToText};
window.dispatchEvent(new Event('diora-podcast-article'));
// At the start of diora.
setTimeout(() => { pickUp(); }, 4000);
}

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v169';
const CACHE = 'diora-v170';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js
// Copied from diora-android 7bb865c 2026-11-09: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@ -273,6 +273,47 @@
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
}
// ---- sharing an episode: its page from the feed (`link`), else the file / the video's watch URL ----
function shareUrl(e) {
const r = e.raw || {};
const d = e.id > 0 ? storedDetails(e.id) : null;
return r.link || r.episode__link || (d && d.link) || r.audio_url || r.episode__audio_url || (d && d.audio_url) || '';
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(() => true, () => copyTextOld(text));
}
return Promise.resolve(copyTextOld(text));
}
function copyTextOld(text) {
const t = el('textarea');
t.value = text;
t.setAttribute('readonly', '');
t.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(t);
t.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) {}
t.remove();
return ok;
}
// Android: the system's share sheet. Elsewhere (browser, desktop app): title and link onto the clipboard.
async function shareEpisode(e) {
let url = shareUrl(e);
if (!url && e.id > 0 && navigator.onLine) { // the phone's own list data may not know the link yet
const d = await loadDetails(e.id);
url = (d && (d.link || d.audio_url)) || '';
}
if (!url) { toast('Diese Folge hat keine Adresse zum Teilen'); return; }
const headline = e.feed ? `${e.title} – ${e.feed}` : e.title;
if (N && N.shareText) { N.shareText(headline, url); return; }
toast(await copyText(headline + '\n' + url) ?'Link kopiert' : 'Kopieren ging nicht');
}
// GET from our server; a status other than 2xx throws (as a network error does).
async function getJson(url) {
const r = await fetch(url);
@ -581,6 +622,7 @@
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e),
shareItem(e),
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
@ -595,6 +637,10 @@
]);
}
function shareItem(e) {
return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)};
}
function episodeMenu(e, opts) {
if (v2()) { listsMenu(e, opts); return; }
const queued = inQueue(e.id);
@ -602,6 +648,7 @@
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e),
shareItem(e),
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
@ -625,6 +672,7 @@
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e),
shareItem(e),
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)},
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
@ -691,6 +739,7 @@
...dlSettingsItems(),
]),
backgroundSyncItem(),
{icon: '✎', label: 'Transkription', hint: 'Whisper-Instanz für Transkripte und Artikel, Modell, Sprache', action: transcriptionSettings},
{icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
startPageItem(),
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
@ -2833,6 +2882,15 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
return true;
};
// A transcript/article notice was tapped (TranscriptNotices.kt): the episode by id.
window.__dioraOpenEpisodeById = async function (id) {
const d = await loadDetails(id);
if (!d) return false;
return window.__dioraPodcastOpenEpisode(Object.assign({}, d, {
feed__id: d.feed && d.feed.id, feed__title: d.feed && d.feed.title, feed__artwork_url: d.feed && d.feed.artwork_url,
}));
};
window.__dioraPodcastOpenFeed = function (feedId) {
const f = feeds().find(x => x.id === Number(feedId));
if (!f) return false;
@ -4328,6 +4386,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
return;
}
const tr = trOf(id);
trFill(tr, d, !!fresh);
if (live && sheet) {
const fav = sheet.querySelector('.nui-pod-fav');
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
@ -4338,10 +4398,23 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
if (chapBox) chapterBox(chapBox, chapters, id);
const tabs = el('div', 'nui-pod-subtabs');
const body = el('div', 'nui-pod-notesbody');
// Jump to a second of the episode (taps on the article's "▶ ab 12:34" links).
const seek = s => {
if (live) N.podcastSeek(s * 1000);
else if (jump) jump(s);
else toast('Zum Anhören die Folge öffnen');
};
let current = '';
let sig = '';
const sigOf = () => tabList().map(x => x[0]).join() + '|' +
(current === 'transcript' ? (tr.has ? 'has' : trSig(id)) : current === 'article' ? trSig(id) + ':' + tr.article : '');
const show = which => {
current = which;
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
body.textContent = '';
if (which === 'chapters') {
if (which === 'article') {
articlePanel(body, id, seek);
} else if (which === 'chapters') {
const ol = el('ol', 'nui-pod-chapters');
chapters.forEach((c, i) => {
const li = el('li', 'nui-pod-chapter');
@ -4372,6 +4445,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
}
body.append(notes);
}
sig = sigOf();
};
const tab = (which, label) => {
const b = el('button', 'nui-pod-subtab', label);
@ -4380,11 +4454,33 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
b.addEventListener('click', () => show(which));
tabs.append(b);
};
if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
tab('notes', 'Shownotes');
if (d.has_transcript) tab('transcript', 'Transkript');
// The Artikel tab comes first (when there is one); Transkript for every episode with sound
// (not YouTube: the server never fetches its sound), with a button to make one when it is missing.
const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url);
function tabList() {
const list = [];
if (tr.article) list.push(['article', 'Artikel']);
if (chapters.length && !chapBox) list.push(['chapters', `Kapitel (${chapters.length})`]);
list.push(['notes', 'Shownotes']);
if (tr.has || canTranscribe) list.push(['transcript', 'Transkript']);
return list;
}
const draw = want => {
const list = tabList();
tabs.textContent = '';
list.forEach(([w, l]) => tab(w, l));
show(list.some(x => x[0] === want) ? want : list[0][0]);
};
// Called when the state of the episode's transcript / article changes.
box.trUpdate = select => {
if (select) draw(select);
else if (sigOf() !== sig) draw(current);
};
trBoxes.add(box);
box.append(tabs, body);
show(chapters.length && !chapBox ? 'chapters' : 'notes');
draw('');
if (epScreen && epScreen.e.id === id) episodeScreenParts();
trSchedule();
}
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
@ -4455,6 +4551,8 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
async function showTranscript(body, id, live) {
// None yet: the button to make one (or its progress).
if (!trOf(id).has) { transcriptPanel(body, id); return; }
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
let t = transcripts.get(id);
if (!t) {
@ -4468,6 +4566,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
}
}
body.textContent = '';
if (trOf(id).origin === 'whisper') body.append(el('div', 'nui-pod-meta nui-pod-tr-origin', 'automatisch transkribiert'));
const list = el('div', 'nui-pod-transcript');
for (const seg of t.segments || []) {
const p = el('p', 'nui-pod-seg');
@ -4495,6 +4594,493 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
}
}
// ---- smart transcription (diora-web docs/transcription.md) ------------------------------
// The server transcribes (a job per episode, polled every 5 s while it is on screen); the article
// is written on this device with the user's own key (DioraPodcastArticle). One state per episode
// feeds the Transkript tab, the Artikel tab and the "✦ Artikel" button.
const trStates = new Map(); // episode id -> {job, writing, has, origin, article}
const trBoxes = new Set(); // the details boxes on screen (showDetails)
const trArticles = new Map(); // feed item id -> its item (the article's text)
let trTimer = null;
async function trFetch(method, url, body) {
const headers = {'Content-Type': 'application/json'};
if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken();
const r = await fetch(url, {method, headers, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body)});
let data = null;
try { data = await r.json(); } catch (e) {}
return {ok: r.ok, status: r.status, data};
}
function trFill(t, d, fresh) {
if (fresh || !t.has) t.has = !!d.has_transcript;
if (fresh || !t.origin) t.origin = d.transcript_origin || null;
if (fresh || t.article == null) t.article = d.article_item_id || null;
if ((fresh || !t.job) && 'transcription' in d) t.job = d.transcription || null;
}
function trOf(id) {
let t = trStates.get(id);
if (!t) {
t = {job: null, writing: null, has: false, origin: null, article: null, polling: false};
trStates.set(id, t);
const d = storedDetails(id);
if (d) trFill(t, d, true);
}
return t;
}
const trActive = j => !!j && (j.state === 'queued' || j.state === 'downloading' || j.state === 'transcribing');
// What is going on with the episode right now (a transcription on the server, or the article
// being written here), as the line the buttons show; null when nothing.
function trStatus(id) {
const t = trOf(id);
if (t.writing) return {text: t.writing.text, kind: 'writing'};
const j = t.job;
if (!trActive(j)) return null;
const text = j.state === 'queued' ? 'Wartet …'
: j.state === 'downloading' ? 'Lade Folge …'
: j.chunks_total ? `Transkribiere ${j.chunks_done || 0}/${j.chunks_total} …` : 'Transkribiere …';
return {text, kind: 'job'};
}
// Changes only when what a view shows changes.
function trSig(id) {
const t = trOf(id);
const st = trStatus(id);
return (st ? st.text : '') + '|' + (t.job && t.job.state === 'error' ? t.job.error || 'x' : '');
}
const trVisible = id => (epScreen && epScreen.e.id === id && !!notesSheet && notesSheet.isConnected) ||
[...trBoxes].some(b => b.isConnected && b.dataset.episode === String(id));
function trChanged(id, select) {
for (const b of [...trBoxes]) {
if (!b.isConnected) { trBoxes.delete(b); continue; }
if (b.dataset.episode === String(id) && b.trUpdate) b.trUpdate(select);
}
if (epScreen && epScreen.e.id === id) episodeScreenParts();
trSchedule();
}
// Poll the jobs on screen every five seconds; nothing runs when none is shown.
function trSchedule() {
const ids = [...trStates].filter(([id, t]) => trActive(t.job) && trVisible(id)).map(([id]) => id);
if (ids.length && !trTimer) {
trTimer = setInterval(trTick, 5000);
trTick();
} else if (!ids.length && trTimer) {
clearInterval(trTimer);
trTimer = null;
}
}
async function trTick() {
if (document.hidden) return;
for (const [id, t] of [...trStates]) {
if (!trActive(t.job) || !trVisible(id) || t.polling) continue;
t.polling = true;
try {
const r = await trFetch('GET', `/podcasts/episodes/${id}/transcribe/`);
if (r.ok && r.data) await trApply(id, r.data);
} catch (e) {} finally { t.polling = false; }
}
trSchedule();
}
async function trApply(id, job) {
const t = trOf(id);
if (!job || job.state === 'none') { t.job = null; trChanged(id); return; }
t.job = job;
if (job.state !== 'done') { trChanged(id); return; }
const want = !!job.want_article;
t.job = null;
t.has = true;
transcripts.delete(id);
await trFresh(id);
trChanged(id);
// diora is open: the article is written right away (when this device has a key; else it waits).
if (want && !t.article && !t.writing) writeArticle(id, {claim: true});
}
async function trFresh(id) {
const d = await loadDetails(id);
if (d) trFill(trOf(id), d, true);
return d;
}
async function startTranscription(id, article) {
const t = trOf(id);
if (!navigator.onLine) { toast('Das braucht eine Verbindung'); return; }
let r;
try { r = await trFetch('POST', `/podcasts/episodes/${id}/transcribe/`, {article: !!article}); } catch (e) { r = {ok: false, status: 0}; }
if (!r.ok) {
const err = r.data && r.data.error;
const msg = err === 'youtube' ? 'YouTube-Folgen lassen sich nicht transkribieren.'
: err === 'too large' ? 'Die Folge ist zu groß zum Transkribieren (über 800 MB).'
: r.status ? `Das Transkribieren ließ sich nicht starten (${r.status}).` : 'Keine Verbindung.';
t.job = {state: 'error', error: msg, want_article: !!article};
trChanged(id);
return;
}
const job = r.data || {};
if (job.state === 'done') {
t.job = null;
t.has = true;
await trFresh(id);
trChanged(id);
if (article) writeArticle(id);
return;
}
t.job = Object.assign({state: 'queued', want_article: !!article}, job);
if (article && window.DioraPodcastArticle) DioraPodcastArticle.watch(true);
trChanged(id);
}
async function cancelTranscription(id) {
const t = trOf(id);
try { await trFetch('DELETE', `/podcasts/episodes/${id}/transcribe/`); } catch (e) {}
t.job = null;
trChanged(id);
}
// The key first: without one the settings open (write() would wait for ever behind the sheet).
async function trKeyReady() {
const AI = window.DioraAI;
if (!AI || !AI.available()) { toast('Die KI-Funktionen gibt es hier nicht'); return false; }
let has = false;
try { has = await AI.hasKey(); } catch (e) {}
if (!has) {
toast('Für den Artikel braucht diora deinen KI-Schlüssel');
AI.settings();
}
return has;
}
async function articleAction(id) {
const st = trStatus(id);
if (st) { toast(st.text); return; }
if (!(await trKeyReady())) return;
if (trOf(id).has) writeArticle(id);
else startTranscription(id, true);
}
// Write the article here and save it. `claim`: a wish of the server (reserve it first, so that the
// phone and the desktop don't both write it) and no questions when there is no key.
async function writeArticle(id, opts) {
const claim = !!(opts && opts.claim);
const t = trOf(id);
if (t.writing) return;
const AI = window.DioraAI;
const PA = window.DioraPodcastArticle;
if (!AI || !AI.available() || !PA) { if (!claim) toast('Artikel schreiben geht hier nicht'); return; }
if (claim) {
let has = false;
try { has = await AI.hasKey(); } catch (e) {}
if (!has) return;
}
const ctrl = new AbortController();
t.writing = {text: 'Lade Transkript …', ctrl};
trChanged(id);
let claimed = false;
try {
if (claim) {
const c = await PA.claim(id);
if (!c.ok) { t.writing = null; trChanged(id); return; } // another device has it
claimed = true;
}
let last = 0;
const saved = await PA.write(id, {
signal: ctrl.signal,
onState: s => {
if (!t.writing) return;
t.writing.text = s.text;
const now = Date.now();
if (s.phase !== 'writing' || now - last > 1000) { last = now; trChanged(id); }
},
});
if (t.article) trArticles.delete(t.article);
t.article = saved.item_id;
t.writing = null;
await trFresh(id);
if (claim && PA.notifyDone) PA.notifyDone(saved.title, id); else toast('Artikel fertig');
trChanged(id, 'article');
} catch (e) {
t.writing = null;
if (claimed) { try { await PA.release(id); } catch (e2) {} }
trChanged(id);
if (ctrl.signal.aborted) toast('Abgebrochen');
else if (AI.showError) AI.showError('Artikel schreiben', e);
else toast((e && e.message) || 'Der Artikel ließ sich nicht schreiben');
}
}
async function removeArticle(id) {
const t = trOf(id);
const ok = typeof customConfirm === 'function'
? await customConfirm('Den Artikel zu dieser Folge entfernen? Er verschwindet auch aus „Podcast-Artikel“ in den Blogs.', {danger: true}) : true;
if (!ok) return;
let r;
try { r = await trFetch('DELETE', `/podcasts/episodes/${id}/article/`); } catch (e) { r = {ok: false}; }
if (!r.ok) { toast('Entfernen ging nicht'); return; }
if (t.article) trArticles.delete(t.article);
t.article = null;
await trFresh(id);
trChanged(id);
toast('Artikel entfernt');
}
// The Transkript tab of an episode without a transcript: start, progress, error.
function transcriptPanel(body, id) {
const t = trOf(id);
body.textContent = '';
const st = trStatus(id);
const wrap = el('div', 'nui-pod-tr');
if (st) {
wrap.append(el('div', 'nui-pod-tr-status', st.text));
if (st.kind === 'job') {
wrap.append(el('div', 'nui-pod-meta', t.job.want_article
? 'Das dauert je nach Länge Minuten bis Stunden. Danach schreibt diora den Artikel, wenn diora offen ist.'
: 'Das dauert je nach Länge Minuten bis Stunden. Du bekommst eine Benachrichtigung, wenn es fertig ist.'));
wrap.append(trButton('Abbrechen', () => cancelTranscription(id)));
} else {
wrap.append(trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
}
} else if (t.job && t.job.state === 'error') {
wrap.append(el('div', 'nui-pod-tr-error', t.job.error || 'Das Transkribieren ist fehlgeschlagen.'));
wrap.append(trButton('Erneut versuchen', () => startTranscription(id, !!t.job.want_article), true));
} else {
wrap.append(el('div', 'nui-pod-meta', 'Diese Folge hat kein Transkript. Der Server kann es erstellen; das dauert je nach Länge ' +
'Minuten bis Stunden, am Ende kommt eine Benachrichtigung.'));
wrap.append(trButton('Transkript erstellen', () => startTranscription(id, false), true));
}
body.append(wrap);
}
function trButton(label, run, primary) {
const b = el('button', 'btn nui-pod-trbtn' + (primary ? ' btn-primary' : ''), label);
b.type = 'button';
b.addEventListener('click', run);
return b;
}
// The Artikel tab: the article from Blogs (a feed item), its three actions, taps on "▶ ab 12:34" seek.
async function articlePanel(body, id, seek) {
const t = trOf(id);
const itemId = t.article;
body.textContent = '';
const bar = el('div', 'nui-pod-trbar');
const st = trStatus(id);
if (st && st.kind === 'writing') {
bar.append(el('span', 'nui-pod-tr-status', st.text), trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
} else {
if (window.__dioraFeedsOpenItem) {
bar.append(trButton('In Blogs öffnen', () => {
closeNotes();
closePlayer();
window.__dioraFeedsOpenItem(itemId);
}));
}
if (window.DioraAI && DioraAI.available() && !st) bar.append(trButton('Neu erstellen', () => rewriteArticle(id)));
bar.append(trButton('Entfernen', () => removeArticle(id)));
}
const text = el('div', 'nui-pod-notes nui-pod-article');
body.append(bar, text);
let item = trArticles.get(itemId);
if (!item) {
text.append(el('div', 'nui-pod-meta', 'Artikel wird geladen …'));
try {
item = (await getJson(`/feeds/items/${itemId}/`)).item;
if (item) trArticles.set(itemId, item);
} catch (e) {}
if (!text.isConnected || t.article !== itemId) return;
text.textContent = '';
}
if (!item) {
text.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Der Artikel ließ sich nicht laden.' : 'Der Artikel braucht eine Verbindung.'));
return;
}
// The server cleaned the HTML (feeds/fetch.py clean_html), as for every feed item.
if (item.content) text.innerHTML = item.content;
else text.append(el('p', null, item.summary || ''));
text.querySelectorAll('a[href]:not([href^="#t="])').forEach(a => a.setAttribute('target', '_blank'));
text.addEventListener('click', ev => {
const a = ev.target.closest('a[href^="#t="]');
if (!a) return;
ev.preventDefault();
const s = parseInt(a.getAttribute('href').slice(3), 10);
if (s >= 0) seek(s);
});
}
// "Neu erstellen" replaces the article: ask first.
async function rewriteArticle(id) {
const ok = typeof customConfirm === 'function'
? await customConfirm('Den Artikel neu schreiben? Der jetzige wird ersetzt.') : true;
if (ok) articleAction(id);
}
// The "✦ Artikel" button of the episode screen, or null where it isn't offered.
function trArticleButton(e) {
const id = e.id;
if (!(id > 0)) return null;
const t = trOf(id);
const d = storedDetails(id) || {};
const url = (e.raw && (e.raw.audio_url || e.raw.episode__audio_url)) || d.audio_url || '';
if (e.yt || !url || isYouTube(url)) return null;
const st = trStatus(id);
if (st) return {label: st.text, run: () => toast(st.text), cls: 'on'};
if (t.article) return {label: 'Artikel', run: () => { if (epScreen && epScreen.box && epScreen.box.trUpdate) epScreen.box.trUpdate('article'); }, cls: 'on'};
if (!(window.DioraAI && DioraAI.available())) return null;
return {label: t.has ? 'Artikel erstellen' : 'Transkribieren und Artikel erstellen', run: () => articleAction(id)};
}
// ---- settings of the transcription instance (GET/POST /podcasts/transcription/settings/) ----
const TR_LANGUAGES = [['', 'Automatisch'], ['de', 'Deutsch'], ['en', 'Englisch'], ['fr', 'Französisch'], ['es', 'Spanisch'],
['it', 'Italienisch'], ['nl', 'Niederländisch'], ['pt', 'Portugiesisch'], ['pl', 'Polnisch'], ['ru', 'Russisch'],
['tr', 'Türkisch'], ['uk', 'Ukrainisch'], ['ja', 'Japanisch'], ['zh', 'Chinesisch']];
let trSheet = null;
function closeTrSettings() {
if (!trSheet) return false;
trSheet.remove();
trSheet = null;
return true;
}
async function transcriptionSettings() {
closeTrSettings();
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-trset', 'Schließen', closeTrSettings);
trSheet = made;
const body = el('div', 'nui-pod-trform');
panel.append(top, el('div', 'nui-pod-player-title', 'Transkription'), body);
document.body.append(made);
body.append(el('div', 'nui-pod-meta', 'Einstellungen werden geladen …'));
let s;
try {
const r = await trFetch('GET', '/podcasts/transcription/settings/');
if (!r.ok) throw new Error(r.status);
s = r.data;
} catch (e) {
body.textContent = '';
body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Die Einstellungen ließen sich nicht laden.' : 'Die Einstellungen brauchen eine Verbindung.'));
return;
}
if (trSheet !== made) return;
drawTranscriptionSettings(body, s, made);
}
function drawTranscriptionSettings(body, s, made) {
body.textContent = '';
const field = (label, input) => {
const l = el('label', 'nui-pod-trfield');
l.append(el('span', null, label), input);
return l;
};
const text = (type, value, placeholder) => {
const i = el('input', 'search-input');
i.type = type;
i.value = value == null ? '' : value;
if (placeholder) i.placeholder = placeholder;
i.setAttribute('autocomplete', 'off');
i.setAttribute('autocapitalize', 'off');
i.spellcheck = false;
return i;
};
body.append(el('div', 'nui-pod-meta', 'Der Server schneidet die Folge in Stücke und schickt sie an eine Whisper-Instanz ' +
'(OpenAI-kompatibel). Das passiert nur, wenn du es in einer Folge auslöst.'));
let engine = s.engine === 'builtin' && s.builtin_available ? 'builtin' : 'custom';
const engines = el('div', 'nui-pod-subtabs');
const options = [s.builtin_available && ['builtin', 'Eingebaute Instanz'], ['custom', 'Eigene Instanz']].filter(Boolean);
const url = text('url', s.url, 'https://whisper.example.org/v1');
const token = text('password', '', s.has_token ? 'gespeichert (leer lassen = behalten)' : 'optional');
token.setAttribute('autocomplete', 'new-password');
const urlField = field('Adresse', url);
const tokenField = field('Token', token);
const sync = () => {
engines.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.engine === engine));
urlField.hidden = tokenField.hidden = engine !== 'custom';
};
for (const [value, label] of options) {
const b = el('button', 'nui-pod-subtab', label);
b.type = 'button';
b.dataset.engine = value;
b.addEventListener('click', () => { engine = value; sync(); });
engines.append(b);
}
body.append(engines, urlField, tokenField);
const model = text('text', s.model, 'z. B. large-v3-turbo');
const lang = el('select', 'search-input');
const langs = TR_LANGUAGES.some(l => l[0] === (s.language || '')) ? TR_LANGUAGES : TR_LANGUAGES.concat([[s.language, s.language]]);
for (const [v, l] of langs) {
const o = el('option', null, l);
o.value = v;
lang.append(o);
}
lang.value = s.language || '';
const par = text('number', s.parallel || 3);
par.min = '1';
par.max = '8';
par.inputMode = 'numeric';
body.append(field('Modell', model), field('Sprache', lang), field('Parallel (1 bis 8 Stücke gleichzeitig)', par));
const status = el('div', 'nui-pod-tr-status');
status.setAttribute('aria-live', 'polite');
const say = (msg, bad) => { status.textContent = msg; status.classList.toggle('nui-pod-tr-error', !!bad); };
// Save; the token is sent only when typed (empty keeps it) or removed on purpose.
const save = async removeToken => {
if (engine === 'custom' && !url.value.trim()) { say('Bitte eine Adresse eintragen.', true); return null; }
const post = {
engine, url: url.value.trim(), model: model.value.trim(), language: lang.value,
parallel: Math.max(1, Math.min(8, parseInt(par.value, 10) || 3)),
};
if (removeToken) post.token = '';
else if (token.value) post.token = token.value;
const r = await trFetch('POST', '/podcasts/transcription/settings/', post);
if (!r.ok) { say((r.data && r.data.error) || `Speichern ging nicht (${r.status}).`, true); return null; }
let next = r.data && 'engine' in r.data ? r.data : null;
if (!next) {
const g = await trFetch('GET', '/podcasts/transcription/settings/');
next = g.ok ? g.data : Object.assign({}, s, post, {has_token: !!(s.has_token && !removeToken) || !!post.token});
}
return next;
};
const run = fn => async () => {
try { await fn(); } catch (e) { say(navigator.onLine ? 'Das ging nicht.' : 'Keine Verbindung.', true); }
};
const buttons = el('div', 'nui-pod-trbar');
buttons.append(
trButton('Speichern', run(async () => {
const next = await save(false);
if (next && trSheet === made) { toast('Gespeichert'); drawTranscriptionSettings(body, next, made); }
}), true),
trButton('Verbindung testen', run(async () => {
say('Speichere und teste …');
const next = await save(false);
if (!next) return;
const r = await trFetch('POST', '/podcasts/transcription/test/', {});
if (trSheet !== made) return;
drawTranscriptionSettings(body, next, made);
const out = body.querySelector('.nui-pod-tr-status');
const ok = r.ok && r.data && r.data.ok;
out.textContent = ok ? `Verbindung steht${r.data.model ? ' (Modell ' + r.data.model + ')' : ''}.`
: 'Verbindung fehlgeschlagen: ' + ((r.data && r.data.error) || `Fehler ${r.status}`);
out.classList.toggle('nui-pod-tr-error', !ok);
})));
if (s.has_token) {
buttons.append(trButton('Token entfernen', run(async () => {
const next = await save(true);
if (next && trSheet === made) { toast('Token entfernt'); drawTranscriptionSettings(body, next, made); }
})));
}
body.append(buttons, status);
if (s.has_token) body.append(el('div', 'nui-pod-meta', 'Token: gespeichert'));
sync();
}
// ---- the episode screen: a tap on a row opens it ----------------------------------------
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
@ -4552,7 +5138,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
}).catch(() => {});
}
document.body.append(notesSheet);
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
epScreen = {e, ctx, actions, playBtn, box, facts: info.querySelector('.nui-pod-epfacts')};
episodeScreenParts();
showDetails(box, e.id, false, seconds => playAt(e, seconds));
}
@ -4613,6 +5199,9 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
if (N.podcastDiscard) {
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
}
act(null, '⇪', 'Teilen', () => shareEpisode(e));
const art = trArticleButton(e);
if (art) act(null, '✦', art.label, art.run, art.cls);
const fav = e.raw.favorite;
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
fav ? 'on' : '');
@ -5828,6 +6417,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
window.__dioraBack = function () {
if (closeStats()) return true;
if (closeDownloads()) return true;
if (closeTrSettings()) return true;
if (closeNotes()) return true;
if (closePlayer()) return true;
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 5227c26 2026-10-26: the podcast parts of web-native/static/native.css
/* Copied from diora-android 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
@ -550,6 +550,21 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
.nui-pod-epact-danger { color: var(--red, #e5484d); }
/* Transcript and article of an episode (the Transkript / Artikel tabs, the transcription settings). */
.nui-pod-tr { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 6px 2px; }
.nui-pod-tr-status { font-size: 14.5px; font-weight: 600; }
.nui-pod-tr-error { font-size: 14px; color: var(--red, #e5484d); }
.nui-pod-tr-origin { margin-bottom: 6px; font-style: italic; }
.nui-pod-trbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; }
.nui-pod-trbtn { min-height: 40px; }
.nui-pod-article h2 { font-size: 17px; margin: 1.2em 0 0.2em; }
.nui-pod-article p { margin: 0.5em 0; }
.nui-pod-article blockquote { margin: 0.6em 0; padding-left: 12px; border-left: 3px solid var(--border); }
.nui-pod-trform { display: flex; flex-direction: column; gap: 10px; }
.nui-pod-trfield { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--nui-muted); }
.nui-pod-trfield input, .nui-pod-trfield select { width: 100%; box-sizing: border-box; font-size: 15px; color: inherit; }
.nui-pod-trfield[hidden] { display: none; }
body.eink .nui-pod-tr-error { color: #000; }
body.eink .nui-pod-epact { border-color: #000; color: #000; }
body.eink .nui-pod-epact-danger { color: #000; }
body.eink .nui-pod-chapter.current,

View file

@ -400,7 +400,8 @@
<script src="/static/js/app/80-radio-sidebar.js"></script>
<script src="/static/js/app/99-init.js"></script>
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
<script type="module" src="/static/js/ai.js"></script>{% endif %}
<script type="module" src="/static/js/ai.js"></script>
<script type="module" src="/static/js/podcast-article.js"></script>{% endif %}
<script src="/static/js/podcasts-web.js"></script>
<script src="/static/js/home.js"></script>
<script src="/static/js/feeds.js"></script>

View file

@ -0,0 +1,172 @@
// Tests of static/js/podcast-article.js under Node: node --test tools/tests/
// No network, no LLM: fetch and DioraAI are stubbed.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as pa from '../../static/js/podcast-article.js';
test('transcriptToText groups 30 seconds and splits on speaker change', () => {
const segs = [
{start: 0, end: 5, speaker: '', text: 'Hallo zusammen.'},
{start: 20, end: 29, speaker: '', text: 'Heute geht es um X.'},
{start: 31, end: 40, speaker: '', text: 'Zweite Gruppe.'},
{start: 3725, end: 3730, speaker: '', text: 'Spät.'},
{start: 3726, end: 3730, speaker: '', text: ' '},
];
assert.equal(pa.transcriptToText(segs),
'[00:00] Hallo zusammen. Heute geht es um X.\n[00:31] Zweite Gruppe.\n[62:05] Spät.');
const sp = [
{start: 0, speaker: 'Anna', text: 'Eins.'},
{start: 4, speaker: 'Anna', text: 'Zwei.'},
{start: 8, speaker: 'Bernd', text: 'Drei.'},
];
assert.equal(pa.transcriptToText(sp), '[00:00] Anna: Eins. Zwei.\n[00:08] Bernd: Drei.');
assert.equal(pa.transcriptToText([]), '');
});
test('clock and mmss', () => {
assert.equal(pa.mmss(754), '12:34');
assert.equal(pa.clock(754), '12:34');
assert.equal(pa.clock(3725), '1:02:05');
});
test('token estimate and the limit message', () => {
assert.equal(pa.estimateTokens('a'.repeat(400)), 100);
const big = {segments: Array.from({length: 20000}, (_, i) => ({start: i * 31, speaker: '', text: 'x'.repeat(30)}))};
assert.throws(() => pa.buildPrompt({title: 'T'}, big), e => e.diora && /zu lang/.test(e.message));
const ok = pa.buildPrompt({title: 'T', feed: {title: 'P'}, description: '<p>Notes &amp; mehr</p>', duration_seconds: 3725,
chapters: [{start: 60, title: 'Kap 1'}]}, {segments: [{start: 0, text: 'Hallo'}]});
assert.match(ok, /Podcast: P\nFolge: T\nDauer: 1:02:05\nShownotes:\nNotes & mehr\nKapitel:\n\[01:00\] Kap 1\n\nTranskript:\n\[00:00\] Hallo$/);
assert.throws(() => pa.buildPrompt({title: 'T'}, {segments: []}), /leer/);
});
test('system prompt is the one of the contract', async () => {
const fs = await import('node:fs');
const doc = fs.readFileSync(new URL('../../docs/transcription.md', import.meta.url), 'utf8').split('\r\n').join('\n');
assert.ok(doc.includes(pa.SYSTEM));
assert.deepEqual(pa.SCHEMA.required, ['title', 'summary', 'kind', 'speakers', 'sections']);
});
test('markdownToHtml: paragraphs, emphasis, lists, quotes, escaping', () => {
assert.equal(pa.markdownToHtml('Eins *kursiv* und **fett**.\nWeiter.\n\nZwei.'),
'<p>Eins <em>kursiv</em> und <strong>fett</strong>. Weiter.</p>\n<p>Zwei.</p>');
assert.equal(pa.markdownToHtml('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
assert.equal(pa.markdownToHtml('1. a\n2. b'), '<ol><li>a</li><li>b</li></ol>');
assert.equal(pa.markdownToHtml('> „Zitat“\n> mehr'), '<blockquote><p>„Zitat“ mehr</p></blockquote>');
const evil = pa.markdownToHtml('<script>alert(1)</script> **<img src=x onerror=y>** [l](javascript:x) "q"');
assert.ok(!/<script|<img/.test(evil));
assert.ok(evil.includes('&lt;script&gt;') && evil.includes('<strong>&lt;img'));
assert.equal(pa.markdownToHtml('2 * 3 * 4'), '<p>2 * 3 * 4</p>');
});
test('articleHtml: summary, heading, time link, body', () => {
const h = pa.articleHtml({summary: 'Kurz & gut.', sections: [
{heading: 'Erste <b>Idee</b>', start: 754.9, body_markdown: 'Text.'}]});
assert.equal(h, '<p>Kurz &amp; gut.</p>\n<h2>Erste &lt;b&gt;Idee&lt;/b&gt;</h2>\n<p><a href="#t=754">▶ ab 12:34</a></p>\n<p>Text.</p>');
});
// ---- stubs ---------------------------------------------------------------------------------
function stubWorld({pending, claims = {}, hasKey = true, askImpl, saveStatus = 200}) {
const calls = [];
const notices = [];
globalThis.DioraNative = undefined;
globalThis.DioraAI = {
available: () => true,
hasKey: async () => hasKey,
withKey: fn => fn(),
modelName: () => 'test-model',
toast: t => notices.push(t),
ask: askImpl || (async opts => {
opts.onProgress && opts.onProgress(1200);
return {title: 'Titel', summary: 'Sum.', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]};
}),
};
globalThis.fetch = async (url, init = {}) => {
const method = init.method || 'GET';
calls.push(method + ' ' + url + (init.body ? ' ' + init.body : ''));
const json = (status, data) => ({status, ok: status < 400, json: async () => data});
if (url === '/podcasts/transcription/pending/') return json(200, pending);
let m = /^\/podcasts\/episodes\/(\d+)\/article\/claim\/$/.exec(url);
if (m) return method === 'POST' ? json(claims[m[1]] || 200, {}) : json(200, {});
m = /^\/podcasts\/episodes\/(\d+)\/transcript\/$/.exec(url);
if (m) return m[1] === '3' ? json(404, {error: 'no transcript'}) : json(200, {segments: [{start: 0, speaker: '', text: 'Hallo'}]});
m = /^\/podcasts\/episodes\/(\d+)\/article\/$/.exec(url);
if (m) return json(saveStatus, {item_id: 900 + Number(m[1])});
m = /^\/podcasts\/episodes\/(\d+)\/$/.exec(url);
if (m) return json(200, {id: Number(m[1]), title: 'Folge ' + m[1], description: '', chapters: [], feed: {title: 'P'}});
return json(404, {});
};
return {calls, notices};
}
test('write: loads, asks with the contract options, saves the HTML', async () => {
const w = stubWorld({pending: []});
let opts;
globalThis.DioraAI.ask = async o => { opts = o; o.onProgress(5); return {title: 'T', summary: 'S', kind: 'Monolog', speakers: [], sections: [{heading: 'H', start: 61, body_markdown: 'B'}]}; };
const states = [];
const r = await pa.write(7, {onState: s => states.push(s.phase)});
assert.deepEqual(r, {item_id: 907, title: 'T'});
assert.equal(opts.effort, 'medium');
assert.equal(opts.maxTokens, 32000);
assert.equal(opts.system, pa.SYSTEM);
assert.match(opts.prompt, /Transkript:\n\[00:00\] Hallo$/);
assert.deepEqual(states, ['loading', 'writing', 'saving']);
const post = w.calls.find(c => c.startsWith('POST /podcasts/episodes/7/article/ '));
const body = JSON.parse(post.slice(post.indexOf('{')));
assert.equal(body.model, 'test-model');
assert.equal(body.kind, 'Monolog');
assert.match(body.body_html, /<a href="#t=61">▶ ab 01:01<\/a>/);
});
test('write: no transcript gives a German message', async () => {
stubWorld({pending: []});
await assert.rejects(pa.write(3), /noch kein Transkript/);
});
test('pickUp: claim, 409 skipped, failure releases the claim, notifier per success', async () => {
pa._reset();
const w = stubWorld({
pending: [{episode_id: 1, title: 'Eins'}, {episode_id: 2, title: 'Zwei'}, {episode_id: 4, title: 'Vier'}],
claims: {2: 409},
askImpl: async o => {
if (o.prompt.includes('Folge 4')) throw new Error('kaputt');
return {title: 'A', summary: 'S', kind: 'Gespräch', speakers: [], sections: [{heading: 'H', start: 0, body_markdown: 'B'}]};
},
});
const r = await pa.pickUp();
assert.deepEqual(r, {done: 1, failed: 1, skipped: 1});
assert.ok(w.calls.some(c => /^POST \/podcasts\/episodes\/2\/article\/claim\/ \{"device":"d-/.test(c)));
assert.ok(!w.calls.some(c => c.startsWith('GET /podcasts/episodes/2/'))); // 409: not written
assert.ok(w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/4/article/claim/'))); // failed: released
assert.ok(!w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/1/article/claim/')));
assert.deepEqual(w.notices, ['Artikel fertig: A']);
// The failed one is not tried again in this visit.
const w2 = stubWorld({pending: [{episode_id: 4, title: 'Vier'}]});
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 1});
assert.ok(!w2.calls.some(c => c.includes('claim')));
});
test('pickUp: android bridge notifies instead of the toast', async () => {
pa._reset();
const w = stubWorld({pending: [{episode_id: 1, title: 'Eins'}]});
const got = [];
globalThis.DioraNative = {notify: (...a) => got.push(a)};
await pa.pickUp();
assert.deepEqual(got, [['Artikel fertig', 'Titel', 'episode:1']]);
assert.deepEqual(w.notices, []);
globalThis.DioraNative = undefined;
});
test('pickUp: no key does nothing, not even a request; no second run at the same time', async () => {
pa._reset();
const w = stubWorld({pending: [{episode_id: 1, title: 'x'}], hasKey: false});
assert.deepEqual(await pa.pickUp(), {done: 0, failed: 0, skipped: 0});
assert.deepEqual(w.calls, []);
const w2 = stubWorld({pending: [{episode_id: 1, title: 'x'}]});
const a = pa.pickUp();
const b = await pa.pickUp();
assert.deepEqual(b, {busy: true});
assert.equal((await a).done, 1);
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.includes('/claim/ {"device":')).length, 1);
});