Compare commits
No commits in common. "e8bb70feb065256e26971269f295e878da2354a6" and "1e36d8600b3b53340df53c6c91e3fc19dc22dc87" have entirely different histories.
e8bb70feb0
...
1e36d8600b
37 changed files with 63 additions and 3864 deletions
|
|
@ -1,10 +0,0 @@
|
||||||
# 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"]
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
"""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}
|
|
||||||
|
|
@ -84,9 +84,6 @@ 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'))
|
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ 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).
|
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
|
- 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).
|
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)
|
## Building blocks (`static/js/ai.js`, an ES module)
|
||||||
|
|
||||||
|
|
@ -43,8 +41,6 @@ kept by the server as a blob it can't read:
|
||||||
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
|
(`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
|
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
|
||||||
answers become plain German messages.
|
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
|
- `DioraAI.review({title, intro, items, onApply})`: the suggestions with checkboxes — nothing
|
||||||
changes until the user ticks and presses "Übernehmen".
|
changes until the user ticks and presses "Übernehmen".
|
||||||
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
||||||
|
|
@ -57,6 +53,5 @@ 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 |
|
| 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) |
|
| 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 |
|
| 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).
|
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
|
||||||
|
|
|
||||||
|
|
@ -182,12 +182,3 @@ Listening without committing: one button plays the Strom like a station.
|
||||||
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
|
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
|
||||||
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
|
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
|
||||||
previous time as `state.stream_seen_before` for the marks).
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -13,7 +13,7 @@ class Command(BaseCommand):
|
||||||
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
|
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
qs = Source.objects.filter(synthetic=False).select_related('user').order_by('last_refreshed_at')
|
qs = Source.objects.select_related('user').order_by('last_refreshed_at')
|
||||||
if options['source_id']:
|
if options['source_id']:
|
||||||
qs = qs.filter(pk=options['source_id'])
|
qs = qs.filter(pk=options['source_id'])
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -35,9 +35,6 @@ class Source(models.Model):
|
||||||
fav = models.BooleanField(default=False)
|
fav = models.BooleanField(default=False)
|
||||||
hidden = models.BooleanField(default=False)
|
hidden = models.BooleanField(default=False)
|
||||||
notify = 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).
|
# Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed).
|
||||||
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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()
|
Story.objects.filter(user=user).exclude(items__gone=False).delete()
|
||||||
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
|
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
|
||||||
for source in Source.objects.filter(user=user, kind=Source.BLOG, synthetic=False):
|
for source in Source.objects.filter(user=user, kind=Source.BLOG):
|
||||||
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
|
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
|
||||||
if ids:
|
if ids:
|
||||||
Item.objects.filter(id__in=ids, starred=False).exclude(read_at=None).delete()
|
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):
|
def refresh(sources, deadline=None):
|
||||||
"""Refresh `sources` (downloads in parallel, writes one by one).
|
"""Refresh `sources` (downloads in parallel, writes one by one).
|
||||||
Returns (refreshed, failed, new items)."""
|
Returns (refreshed, failed, new items)."""
|
||||||
sources = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch
|
sources = list(sources)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
keep = {}
|
keep = {}
|
||||||
refreshed = failed = 0
|
refreshed = failed = 0
|
||||||
|
|
|
||||||
|
|
@ -539,56 +539,3 @@ class NewsTextApiTest(TestCase):
|
||||||
self.assertEqual(got['related'], [])
|
self.assertEqual(got['related'], [])
|
||||||
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
|
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
|
||||||
self.assertNotIn('summary', single)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
'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,
|
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
|
||||||
'unread': unread, 'new': new,
|
'unread': unread, 'new': new,
|
||||||
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'synthetic': s.synthetic,
|
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify,
|
||||||
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
|
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
|
||||||
'interval_days': interval,
|
'interval_days': interval,
|
||||||
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
||||||
|
|
@ -108,11 +108,6 @@ def _item_data(i, content=False):
|
||||||
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
|
'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,
|
'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:
|
if content:
|
||||||
d['content'] = i.content
|
d['content'] = i.content
|
||||||
d['content_full'] = i.content_full
|
d['content_full'] = i.content_full
|
||||||
|
|
@ -361,7 +356,7 @@ def add_sources(request):
|
||||||
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
|
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
|
||||||
title=(x.get('title') or url)[:300]))
|
title=(x.get('title') or url)[:300]))
|
||||||
added += created
|
added += created
|
||||||
sources = Source.objects.filter(user=request.user, last_refreshed_at=None, synthetic=False)
|
sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
|
||||||
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
||||||
return JsonResponse({'added': added})
|
return JsonResponse({'added': added})
|
||||||
|
|
||||||
|
|
@ -391,8 +386,6 @@ def edit_source(request, pk):
|
||||||
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
||||||
source.fulltext = body['fulltext']
|
source.fulltext = body['fulltext']
|
||||||
new_kind = _kind(body.get('kind'))
|
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:
|
if new_kind and new_kind != source.kind:
|
||||||
# A section becomes a folder and back.
|
# A section becomes a folder and back.
|
||||||
if new_kind == Source.BLOG and source.category and not source.folders:
|
if new_kind == Source.BLOG and source.category and not source.folders:
|
||||||
|
|
@ -423,7 +416,7 @@ def export_opml(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
groups = {}
|
groups = {}
|
||||||
for s in Source.objects.filter(user=request.user, synthetic=False).order_by('kind', 'category', 'title'):
|
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
|
||||||
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
|
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
|
||||||
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
|
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
|
||||||
label = kind + (f' – {place}' if place else '')
|
label = kind + (f' – {place}' if place else '')
|
||||||
|
|
@ -447,7 +440,7 @@ def export_opml(request):
|
||||||
def refresh_now(request):
|
def refresh_now(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user, synthetic=False),
|
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user),
|
||||||
deadline=time.monotonic() + REFRESH_WAIT)
|
deadline=time.monotonic() + REFRESH_WAIT)
|
||||||
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
|
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
|
||||||
|
|
||||||
|
|
@ -618,7 +611,7 @@ def story_overview(request, pk):
|
||||||
def blogs(request):
|
def blogs(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source', 'episode_article')
|
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source')
|
||||||
if request.GET.get('source', '').isdigit():
|
if request.GET.get('source', '').isdigit():
|
||||||
qs = qs.filter(source_id=int(request.GET['source']))
|
qs = qs.filter(source_id=int(request.GET['source']))
|
||||||
elif 'folder' in request.GET:
|
elif 'folder' in request.GET:
|
||||||
|
|
@ -647,7 +640,7 @@ def blogs(request):
|
||||||
def starred(request):
|
def starred(request):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
items = Item.objects.filter(user=request.user, starred=True).select_related('source', 'episode_article').order_by('-published')
|
items = Item.objects.filter(user=request.user, starred=True).select_related('source').order_by('-published')
|
||||||
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
|
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -656,14 +649,13 @@ def starred(request):
|
||||||
def item(request, pk):
|
def item(request, pk):
|
||||||
if (err := _auth(request)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source', 'episode_article').first()
|
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source').first()
|
||||||
if i is None:
|
if i is None:
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
if refresh.needs_article(i, i.source):
|
if refresh.needs_article(i, i.source):
|
||||||
refresh.load_article(i)
|
refresh.load_article(i)
|
||||||
d = _item_data(i, content=True)
|
d = _item_data(i, content=True)
|
||||||
if not i.source.synthetic:
|
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
|
||||||
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
|
|
||||||
return JsonResponse({'item': d})
|
return JsonResponse({'item': d})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -752,32 +744,17 @@ def star(request, pk):
|
||||||
def notifications(request):
|
def notifications(request):
|
||||||
"""What a device should announce: posts marked when they came in, still
|
"""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
|
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)):
|
if (err := _auth(request)):
|
||||||
return err
|
return err
|
||||||
qs = Item.objects.filter(user=request.user, notify=True)
|
qs = Item.objects.filter(user=request.user, notify=True)
|
||||||
last = qs.order_by('-id').values_list('id', flat=True).first() or 0
|
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', '')
|
after = request.GET.get('after', '')
|
||||||
if after.isdigit():
|
if not after.isdigit():
|
||||||
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
|
return JsonResponse({'items': [], 'last': last})
|
||||||
.select_related('source').order_by('-published')[:50])
|
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
|
||||||
out['items'] = [_item_data(i) for i in items]
|
.select_related('source').order_by('-published')[:50])
|
||||||
return JsonResponse(out)
|
return JsonResponse({'items': [_item_data(i) for i in items], 'last': last})
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|
@ -793,8 +770,6 @@ def save_login(request):
|
||||||
password = body.get('password') or ''
|
password = body.get('password') or ''
|
||||||
if source is None or not username or not password:
|
if source is None or not username or not password:
|
||||||
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
|
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
|
||||||
if source.synthetic:
|
|
||||||
return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400)
|
|
||||||
try:
|
try:
|
||||||
adapter = logins.check_and_store(request.user, source.url, username, password)
|
adapter = logins.check_and_store(request.user, source.url, username, password)
|
||||||
except logins.LoginFailed as e:
|
except logins.LoginFailed as e:
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"""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})
|
|
||||||
|
|
@ -128,28 +128,25 @@ FetchUnavailable = ChaptersUnavailable
|
||||||
FetchInvalid = ChaptersInvalid
|
FetchInvalid = ChaptersInvalid
|
||||||
|
|
||||||
|
|
||||||
def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
|
def _open(url, headers):
|
||||||
"""`method` (GET or POST) `url` with every redirect hop checked; the streamed response of
|
"""GET `url` with every redirect hop checked; the streamed response of
|
||||||
the final hop, which the caller has to close. `kwargs` (data, files) go to
|
the final hop, which the caller has to close.
|
||||||
requests; a POST only follows 307/308, which keep the method and body.
|
|
||||||
|
|
||||||
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
||||||
ones that may go away (network trouble, server errors).
|
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):
|
for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
|
||||||
try:
|
try:
|
||||||
assert_safe_url(url)
|
assert_safe_url(url)
|
||||||
except WebDAVInputError as e:
|
except WebDAVInputError as e:
|
||||||
raise ChaptersInvalid(str(e))
|
raise ChaptersInvalid(str(e))
|
||||||
try:
|
try:
|
||||||
send = requests.get if method == 'GET' else requests.post
|
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
|
||||||
r = send(url, timeout=timeout, allow_redirects=False, stream=True, headers=headers, **kwargs)
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise ChaptersUnavailable(e.__class__.__name__)
|
raise ChaptersUnavailable(e.__class__.__name__)
|
||||||
try:
|
try:
|
||||||
_assert_peer_is_safe(r)
|
_assert_peer_is_safe(r)
|
||||||
if r.status_code in redirects:
|
if r.status_code in (301, 302, 303, 307, 308):
|
||||||
location = r.headers.get('Location')
|
location = r.headers.get('Location')
|
||||||
if not location:
|
if not location:
|
||||||
raise ChaptersInvalid('redirect without a location')
|
raise ChaptersInvalid('redirect without a location')
|
||||||
|
|
@ -158,7 +155,7 @@ def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
|
||||||
continue
|
continue
|
||||||
if r.status_code >= 500 or r.status_code == 429:
|
if r.status_code >= 500 or r.status_code == 429:
|
||||||
raise ChaptersUnavailable(f'HTTP {r.status_code}')
|
raise ChaptersUnavailable(f'HTTP {r.status_code}')
|
||||||
if r.status_code >= 400 or r.status_code in (301, 302, 303):
|
if r.status_code >= 400:
|
||||||
raise ChaptersInvalid(f'HTTP {r.status_code}')
|
raise ChaptersInvalid(f'HTTP {r.status_code}')
|
||||||
except WebDAVInputError as e:
|
except WebDAVInputError as e:
|
||||||
r.close()
|
r.close()
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# 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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -164,8 +164,6 @@ class PodcastEpisode(models.Model):
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
audio_url = models.URLField(max_length=1000)
|
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)
|
duration_seconds = models.IntegerField(default=0)
|
||||||
pub_date = models.DateTimeField(null=True, blank=True)
|
pub_date = models.DateTimeField(null=True, blank=True)
|
||||||
episode_number = models.IntegerField(null=True, blank=True)
|
episode_number = models.IntegerField(null=True, blank=True)
|
||||||
|
|
@ -219,68 +217,6 @@ class EpisodeTranscript(models.Model):
|
||||||
return f'Transcript of {self.episode}'
|
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),
|
# An episode counts as played this close to its end (AntennaPod's default),
|
||||||
# the same rule as the apps' "als gehört zählen".
|
# the same rule as the apps' "als gehört zählen".
|
||||||
PLAYED_BEFORE_END_SECONDS = 30
|
PLAYED_BEFORE_END_SECONDS = 30
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,6 @@ ALLOWED_ATTRS = {
|
||||||
URL_ATTRS = {'href', 'src'}
|
URL_ATTRS = {'href', 'src'}
|
||||||
LINK_SCHEMES = {'http', 'https', 'mailto'}
|
LINK_SCHEMES = {'http', 'https', 'mailto'}
|
||||||
IMAGE_SCHEMES = {'http', 'https'}
|
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]+')
|
_CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+')
|
||||||
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
|
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
|
||||||
|
|
@ -84,9 +81,7 @@ class _Sanitizer(HTMLParser):
|
||||||
name = name.lower()
|
name = name.lower()
|
||||||
if name not in allowed or value is None:
|
if name not in allowed or value is None:
|
||||||
continue
|
continue
|
||||||
if tag == 'a' and name == 'href' and TIME_LINK.match(value.strip()):
|
if name in URL_ATTRS:
|
||||||
value = value.strip()
|
|
||||||
elif name in URL_ATTRS:
|
|
||||||
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
|
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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" onfocus="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')
|
|
||||||
|
|
@ -89,7 +89,7 @@ class PlayerSyncTests(TestCase):
|
||||||
self.assertEqual(d['seq'], 2) # the report, and device a coming online
|
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['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((d['device_id'], d['device_name']), ('a', 'A'))
|
||||||
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
|
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
|
||||||
'in_queue'})
|
'in_queue'})
|
||||||
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
|
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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')
|
|
||||||
|
|
@ -1,786 +0,0 @@
|
||||||
"""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))
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
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'])
|
|
||||||
|
|
@ -837,7 +837,7 @@ class EpisodeListTests(TestCase):
|
||||||
a2 = dict(eps[self.a2.id])
|
a2 = dict(eps[self.a2.id])
|
||||||
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
|
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
|
||||||
self.assertEqual(a2, {
|
self.assertEqual(a2, {
|
||||||
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'link': '', 'duration_seconds': 0,
|
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
|
||||||
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
|
'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',
|
'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,
|
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True, 'in_later': False,
|
||||||
|
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
"""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}}
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
"""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})
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import article, audiobooks, discover, folders, radio, transcription, views
|
from . import audiobooks, discover, folders, radio, views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('search/', views.podcast_search, name='podcast_search'),
|
path('search/', views.podcast_search, name='podcast_search'),
|
||||||
|
|
@ -28,15 +28,9 @@ urlpatterns = [
|
||||||
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
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>/', views.episode_detail, name='podcast_episode_detail'),
|
||||||
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
|
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>/silences/', views.episode_silences, name='podcast_episode_silences'),
|
||||||
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
|
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('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('stats/', views.podcast_stats, name='podcast_stats'),
|
||||||
path('queue/', views.queue_get, name='podcast_queue_get'),
|
path('queue/', views.queue_get, name='podcast_queue_get'),
|
||||||
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,11 @@ from . import chapters as chapters_mod
|
||||||
from . import silence as silence_mod
|
from . import silence as silence_mod
|
||||||
from . import sponsor as sponsor_mod
|
from . import sponsor as sponsor_mod
|
||||||
from . import feedext
|
from . import feedext
|
||||||
from . import transcription as transcription_mod
|
|
||||||
from . import transcripts as transcripts_mod
|
from . import transcripts as transcripts_mod
|
||||||
from . import youtube as youtube_mod
|
from . import youtube as youtube_mod
|
||||||
from . import folders as folders_mod
|
from . import folders as folders_mod
|
||||||
from .models import (
|
from .models import (
|
||||||
EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
||||||
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
||||||
)
|
)
|
||||||
from .shownotes import extract_timestamps, sanitize_html
|
from .shownotes import extract_timestamps, sanitize_html
|
||||||
|
|
@ -232,7 +231,7 @@ def _later_data(user):
|
||||||
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
|
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
|
||||||
.values(
|
.values(
|
||||||
'id', 'added_at', 'auto',
|
'id', 'added_at', 'auto',
|
||||||
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
|
'episode__id', 'episode__title', 'episode__audio_url',
|
||||||
'episode__duration_seconds', 'episode__artwork_url',
|
'episode__duration_seconds', 'episode__artwork_url',
|
||||||
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
||||||
'episode__pub_date',
|
'episode__pub_date',
|
||||||
|
|
@ -384,7 +383,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
|
||||||
return bool(changed)
|
return bool(changed)
|
||||||
|
|
||||||
|
|
||||||
EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
|
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
|
||||||
'title', 'pub_date', 'episode_number']
|
'title', 'pub_date', 'episode_number']
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -643,12 +642,6 @@ def _move_to_permanent_url(feed_obj, new_url):
|
||||||
feed_obj.rss_url = 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):
|
def _entry_media_url(entry):
|
||||||
"""The episode's file: its audio enclosure, else a video one (the players
|
"""The episode's file: its audio enclosure, else a video one (the players
|
||||||
play only its sound). '' when there is neither."""
|
play only its sound). '' when there is neither."""
|
||||||
|
|
@ -720,7 +713,6 @@ def _apply_feed(feed_obj, fetched, full=False):
|
||||||
audio_url = _entry_media_url(entry)
|
audio_url = _entry_media_url(entry)
|
||||||
if not audio_url:
|
if not audio_url:
|
||||||
continue
|
continue
|
||||||
link = _entry_link(entry)
|
|
||||||
|
|
||||||
guid = entry.get('id') or entry.get('guid') or audio_url
|
guid = entry.get('id') or entry.get('guid') or audio_url
|
||||||
guid = str(guid)[:1000]
|
guid = str(guid)[:1000]
|
||||||
|
|
@ -745,7 +737,7 @@ def _apply_feed(feed_obj, fetched, full=False):
|
||||||
|
|
||||||
known = existing.get(guid)
|
known = existing.get(guid)
|
||||||
if known is not None:
|
if known is not None:
|
||||||
renamed = {'title': title, 'episode_number': ep_number, 'link': link}
|
renamed = {'title': title, 'episode_number': ep_number}
|
||||||
if pub_date is not None:
|
if pub_date is not None:
|
||||||
renamed['pub_date'] = pub_date
|
renamed['pub_date'] = pub_date
|
||||||
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
|
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
|
||||||
|
|
@ -770,7 +762,6 @@ def _apply_feed(feed_obj, fetched, full=False):
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': description,
|
'description': description,
|
||||||
'audio_url': audio_url[:1000],
|
'audio_url': audio_url[:1000],
|
||||||
'link': link,
|
|
||||||
'duration_seconds': duration_seconds,
|
'duration_seconds': duration_seconds,
|
||||||
'pub_date': pub_date,
|
'pub_date': pub_date,
|
||||||
'episode_number': ep_number,
|
'episode_number': ep_number,
|
||||||
|
|
@ -1333,7 +1324,7 @@ def feed_episodes(request, pk):
|
||||||
|
|
||||||
episodes = list(
|
episodes = list(
|
||||||
feed.episodes.values(
|
feed.episodes.values(
|
||||||
'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
|
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
||||||
'pub_date', 'artwork_url', 'episode_number', 'season_number',
|
'pub_date', 'artwork_url', 'episode_number', 'season_number',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1433,9 +1424,6 @@ def episode_detail(request, pk):
|
||||||
chapters = _load_chapters(ep)
|
chapters = _load_chapters(ep)
|
||||||
|
|
||||||
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
|
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({
|
return JsonResponse({
|
||||||
'id': ep.id,
|
'id': ep.id,
|
||||||
'title': ep.title,
|
'title': ep.title,
|
||||||
|
|
@ -1443,13 +1431,9 @@ def episode_detail(request, pk):
|
||||||
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
|
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
|
||||||
'chapters': chapters,
|
'chapters': chapters,
|
||||||
'transcripts': ep.transcripts,
|
'transcripts': ep.transcripts,
|
||||||
'has_transcript': origin is not None,
|
'has_transcript': _has_transcript(ep),
|
||||||
'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},
|
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
|
||||||
'audio_url': ep.audio_url,
|
'audio_url': ep.audio_url,
|
||||||
'link': ep.link,
|
|
||||||
'artwork_url': ep.artwork_url,
|
'artwork_url': ep.artwork_url,
|
||||||
'duration_seconds': ep.duration_seconds,
|
'duration_seconds': ep.duration_seconds,
|
||||||
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
|
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
|
||||||
|
|
@ -1477,13 +1461,6 @@ def _has_transcript(ep):
|
||||||
return not (cached and cached[0] == sources and cached[1])
|
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
|
@csrf_exempt
|
||||||
@require_http_methods(['GET'])
|
@require_http_methods(['GET'])
|
||||||
def episode_silences(request, pk):
|
def episode_silences(request, pk):
|
||||||
|
|
@ -1526,28 +1503,20 @@ def episode_sponsor(request, pk):
|
||||||
def episode_transcript(request, pk):
|
def episode_transcript(request, pk):
|
||||||
"""The episode's transcript as {"segments": [{start, end, speaker, text}],
|
"""The episode's transcript as {"segments": [{start, end, speaker, text}],
|
||||||
"source": {url, type, language}}, fetched and parsed on first use, then
|
"source": {url, type, language}}, fetched and parsed on first use, then
|
||||||
kept. When the feed has none we can use, the one Whisper made (a finished
|
kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when
|
||||||
TranscriptionJob, docs/transcription.md). 404 {"error": "no transcript" |
|
there's none to show; 502 {"error": "unavailable"} when it couldn't be
|
||||||
"unsupported" | "invalid"} when there's none to show; 502
|
loaded right now."""
|
||||||
{"error": "unavailable"} when it couldn't be loaded right now."""
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
try:
|
try:
|
||||||
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
|
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
|
||||||
except PodcastEpisode.DoesNotExist:
|
except PodcastEpisode.DoesNotExist:
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
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:
|
if not ep.transcripts:
|
||||||
return fallback('no transcript')
|
return JsonResponse({'error': 'no transcript'}, status=404)
|
||||||
sources = _transcript_sources(ep)
|
sources = _transcript_sources(ep)
|
||||||
if not sources:
|
if not sources:
|
||||||
return fallback('unsupported')
|
return JsonResponse({'error': 'unsupported'}, status=404)
|
||||||
|
|
||||||
cached = EpisodeTranscript.objects.filter(episode=ep).first()
|
cached = EpisodeTranscript.objects.filter(episode=ep).first()
|
||||||
if cached is not None and cached.sources == sources:
|
if cached is not None and cached.sources == sources:
|
||||||
|
|
@ -1558,7 +1527,7 @@ def episode_transcript(request, pk):
|
||||||
error = ''
|
error = ''
|
||||||
except chapters_mod.FetchUnavailable as e:
|
except chapters_mod.FetchUnavailable as e:
|
||||||
log.info('transcript of episode %s not available: %s', ep.id, e)
|
log.info('transcript of episode %s not available: %s', ep.id, e)
|
||||||
return fallback('unavailable', 502)
|
return JsonResponse({'error': 'unavailable'}, status=502)
|
||||||
except transcripts_mod.Unsupported as e:
|
except transcripts_mod.Unsupported as e:
|
||||||
log.info('transcript of episode %s unsupported: %s', ep.id, e)
|
log.info('transcript of episode %s unsupported: %s', ep.id, e)
|
||||||
data, error, final = None, 'unsupported', True
|
data, error, final = None, 'unsupported', True
|
||||||
|
|
@ -1570,7 +1539,7 @@ def episode_transcript(request, pk):
|
||||||
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
|
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
|
||||||
)
|
)
|
||||||
if error:
|
if error:
|
||||||
return fallback(error)
|
return JsonResponse({'error': error}, status=404)
|
||||||
return JsonResponse(data)
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1676,7 +1645,7 @@ EPISODE_IDS_MAX = 1000
|
||||||
EPISODE_LIST_LIMIT = 100
|
EPISODE_LIST_LIMIT = 100
|
||||||
EPISODE_LIST_MAX = 500
|
EPISODE_LIST_MAX = 500
|
||||||
EPISODE_LIST_FIELDS = (
|
EPISODE_LIST_FIELDS = (
|
||||||
'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url',
|
'feed__id', 'feed__title', 'feed__artwork_url',
|
||||||
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
|
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
|
||||||
)
|
)
|
||||||
|
|
@ -2041,7 +2010,7 @@ def _queue_data(user):
|
||||||
.select_related('episode__feed')
|
.select_related('episode__feed')
|
||||||
.values(
|
.values(
|
||||||
'id', 'position',
|
'id', 'position',
|
||||||
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
|
'episode__id', 'episode__title', 'episode__audio_url',
|
||||||
'episode__duration_seconds', 'episode__artwork_url',
|
'episode__duration_seconds', 'episode__artwork_url',
|
||||||
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
||||||
'episode__pub_date',
|
'episode__pub_date',
|
||||||
|
|
@ -2537,7 +2506,7 @@ def inbox(request):
|
||||||
|
|
||||||
|
|
||||||
def _inbox_fields(with_description=True):
|
def _inbox_fields(with_description=True):
|
||||||
fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url']
|
'feed__id', 'feed__title', 'feed__artwork_url']
|
||||||
if with_description:
|
if with_description:
|
||||||
fields.insert(2, 'description')
|
fields.insert(2, 'description')
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
|
// 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().
|
// authors, folder names …). Nothing is changed without the user ticking it in review().
|
||||||
//
|
//
|
||||||
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema, effort, maxTokens, onProgress, signal}), review(…)}
|
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
||||||
|
|
@ -116,17 +116,12 @@ 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).
|
// One request with a JSON answer of `schema` (structured outputs).
|
||||||
// Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed,
|
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
|
||||||
// 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 v = await loadVault();
|
||||||
const key = keyOf(v);
|
const key = keyOf(v);
|
||||||
if (!key) throw fail('Kein API-Key hinterlegt.');
|
if (!key) throw fail('Kein API-Key hinterlegt.');
|
||||||
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal});
|
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
|
||||||
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
||||||
const stream = client(key).beta.messages.stream({
|
const stream = client(key).beta.messages.stream({
|
||||||
model: PROVIDERS.anthropic.model,
|
model: PROVIDERS.anthropic.model,
|
||||||
|
|
@ -136,18 +131,8 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, o
|
||||||
system,
|
system,
|
||||||
messages: [{role: 'user', content: prompt}],
|
messages: [{role: 'user', content: prompt}],
|
||||||
output_config: {effort, format: {type: 'json_schema', schema}},
|
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();
|
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 === '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.');
|
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(''));
|
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
|
||||||
|
|
@ -164,8 +149,8 @@ async function openRouterFetch(key, path, init) {
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
|
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
|
||||||
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
|
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
|
||||||
model: PROVIDERS.openrouter.model,
|
model: PROVIDERS.openrouter.model,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
reasoning: {effort},
|
reasoning: {effort},
|
||||||
|
|
@ -176,9 +161,7 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, on
|
||||||
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
|
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.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.');
|
if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
|
||||||
const text = (choice.message && choice.message.content) || '';
|
return parseAnswer((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.)
|
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
|
||||||
|
|
@ -378,8 +361,6 @@ window.__dioraBack = function () {
|
||||||
return back ? back.apply(this, arguments) : false;
|
return back ? back.apply(this, arguments) : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const modelName = () => PROVIDERS[providerOf(vault)].model;
|
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
|
||||||
|
|
||||||
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError,
|
|
||||||
close, toast, explain};
|
close, toast, explain};
|
||||||
window.dispatchEvent(new Event('diora-ai'));
|
window.dispatchEvent(new Event('diora-ai'));
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@
|
||||||
const href = a.getAttribute('href');
|
const href = a.getAttribute('href');
|
||||||
a.removeAttribute('href');
|
a.removeAttribute('href');
|
||||||
if (href.length < 2) return;
|
if (href.length < 2) return;
|
||||||
// "#t=754": a second in a podcast's article, never a note.
|
if (!_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
|
||||||
if (!/^#t=\d+$/.test(href) && !_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
|
|
||||||
a.dataset.footnoteRef = href.slice(1);
|
a.dataset.footnoteRef = href.slice(1);
|
||||||
a.dataset.footnoteHref = href;
|
a.dataset.footnoteHref = href;
|
||||||
a.style.cursor = 'pointer';
|
a.style.cursor = 'pointer';
|
||||||
|
|
|
||||||
|
|
@ -720,10 +720,6 @@
|
||||||
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
|
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
|
||||||
const content = i.content || '';
|
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.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>';
|
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
|
||||||
if (i.paywalled) {
|
if (i.paywalled) {
|
||||||
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
|
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
|
||||||
|
|
@ -804,72 +800,12 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 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.
|
// Clicks in the article (65-article.js hands them over): its other reports, the next article.
|
||||||
function onReaderClick(e) {
|
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]');
|
const b = e.target.closest('[data-r]');
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
const r = b.dataset.r;
|
const r = b.dataset.r;
|
||||||
if (r === 'episode') playEpisode(parseInt(b.dataset.id, 10), null);
|
if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
|
||||||
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
|
|
||||||
else if (r === 'story') {
|
else if (r === 'story') {
|
||||||
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
|
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);
|
if (other) openItem((other.lead || other.items[0]).id, other);
|
||||||
|
|
@ -1528,7 +1464,6 @@ 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-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-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-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 a { color: var(--accent, #e63946); }',
|
||||||
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
|
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
|
||||||
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
||||||
|
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
||||||
.replace(/'|'/g, "'").replace(/&/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v170';
|
const CACHE = 'diora-v169';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.js',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copied from diora-android 7bb865c 2026-11-09: web-native/static/native-podcast.js
|
// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js
|
||||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
// 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.
|
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||||
|
|
||||||
|
|
@ -273,47 +273,6 @@
|
||||||
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
|
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).
|
// GET from our server; a status other than 2xx throws (as a network error does).
|
||||||
async function getJson(url) {
|
async function getJson(url) {
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
|
|
@ -622,7 +581,6 @@
|
||||||
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
|
{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)},
|
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||||
favoriteItem(e),
|
favoriteItem(e),
|
||||||
shareItem(e),
|
|
||||||
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
|
!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 oben', action: () => moveTo(e, 'top')},
|
||||||
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
|
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
|
||||||
|
|
@ -637,10 +595,6 @@
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shareItem(e) {
|
|
||||||
return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)};
|
|
||||||
}
|
|
||||||
|
|
||||||
function episodeMenu(e, opts) {
|
function episodeMenu(e, opts) {
|
||||||
if (v2()) { listsMenu(e, opts); return; }
|
if (v2()) { listsMenu(e, opts); return; }
|
||||||
const queued = inQueue(e.id);
|
const queued = inQueue(e.id);
|
||||||
|
|
@ -648,7 +602,6 @@
|
||||||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
|
{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)},
|
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||||
favoriteItem(e),
|
favoriteItem(e),
|
||||||
shareItem(e),
|
|
||||||
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
|
!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: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
|
||||||
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
||||||
|
|
@ -672,7 +625,6 @@
|
||||||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
|
{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)},
|
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||||
favoriteItem(e),
|
favoriteItem(e),
|
||||||
shareItem(e),
|
|
||||||
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
|
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)},
|
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')},
|
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
|
||||||
|
|
@ -739,7 +691,6 @@
|
||||||
...dlSettingsItems(),
|
...dlSettingsItems(),
|
||||||
]),
|
]),
|
||||||
backgroundSyncItem(),
|
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},
|
{icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
|
||||||
startPageItem(),
|
startPageItem(),
|
||||||
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
||||||
|
|
@ -2882,15 +2833,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
return true;
|
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) {
|
window.__dioraPodcastOpenFeed = function (feedId) {
|
||||||
const f = feeds().find(x => x.id === Number(feedId));
|
const f = feeds().find(x => x.id === Number(feedId));
|
||||||
if (!f) return false;
|
if (!f) return false;
|
||||||
|
|
@ -4386,8 +4328,6 @@ 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.'));
|
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tr = trOf(id);
|
|
||||||
trFill(tr, d, !!fresh);
|
|
||||||
if (live && sheet) {
|
if (live && sheet) {
|
||||||
const fav = sheet.querySelector('.nui-pod-fav');
|
const fav = sheet.querySelector('.nui-pod-fav');
|
||||||
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
|
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
|
||||||
|
|
@ -4398,23 +4338,10 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
if (chapBox) chapterBox(chapBox, chapters, id);
|
if (chapBox) chapterBox(chapBox, chapters, id);
|
||||||
const tabs = el('div', 'nui-pod-subtabs');
|
const tabs = el('div', 'nui-pod-subtabs');
|
||||||
const body = el('div', 'nui-pod-notesbody');
|
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 => {
|
const show = which => {
|
||||||
current = which;
|
|
||||||
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
|
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
|
||||||
body.textContent = '';
|
body.textContent = '';
|
||||||
if (which === 'article') {
|
if (which === 'chapters') {
|
||||||
articlePanel(body, id, seek);
|
|
||||||
} else if (which === 'chapters') {
|
|
||||||
const ol = el('ol', 'nui-pod-chapters');
|
const ol = el('ol', 'nui-pod-chapters');
|
||||||
chapters.forEach((c, i) => {
|
chapters.forEach((c, i) => {
|
||||||
const li = el('li', 'nui-pod-chapter');
|
const li = el('li', 'nui-pod-chapter');
|
||||||
|
|
@ -4445,7 +4372,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}
|
}
|
||||||
body.append(notes);
|
body.append(notes);
|
||||||
}
|
}
|
||||||
sig = sigOf();
|
|
||||||
};
|
};
|
||||||
const tab = (which, label) => {
|
const tab = (which, label) => {
|
||||||
const b = el('button', 'nui-pod-subtab', label);
|
const b = el('button', 'nui-pod-subtab', label);
|
||||||
|
|
@ -4454,33 +4380,11 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
b.addEventListener('click', () => show(which));
|
b.addEventListener('click', () => show(which));
|
||||||
tabs.append(b);
|
tabs.append(b);
|
||||||
};
|
};
|
||||||
// The Artikel tab comes first (when there is one); Transkript for every episode with sound
|
if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
|
||||||
// (not YouTube: the server never fetches its sound), with a button to make one when it is missing.
|
tab('notes', 'Shownotes');
|
||||||
const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url);
|
if (d.has_transcript) tab('transcript', 'Transkript');
|
||||||
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);
|
box.append(tabs, body);
|
||||||
draw('');
|
show(chapters.length && !chapBox ? 'chapters' : 'notes');
|
||||||
if (epScreen && epScreen.e.id === id) episodeScreenParts();
|
|
||||||
trSchedule();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
|
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
|
||||||
|
|
@ -4551,8 +4455,6 @@ 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
|
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
|
||||||
|
|
||||||
async function showTranscript(body, id, live) {
|
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 …'));
|
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
|
||||||
let t = transcripts.get(id);
|
let t = transcripts.get(id);
|
||||||
if (!t) {
|
if (!t) {
|
||||||
|
|
@ -4566,7 +4468,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body.textContent = '';
|
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');
|
const list = el('div', 'nui-pod-transcript');
|
||||||
for (const seg of t.segments || []) {
|
for (const seg of t.segments || []) {
|
||||||
const p = el('p', 'nui-pod-seg');
|
const p = el('p', 'nui-pod-seg');
|
||||||
|
|
@ -4594,493 +4495,6 @@ 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 ----------------------------------------
|
// ---- 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;
|
// 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.
|
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
|
||||||
|
|
@ -5138,7 +4552,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
document.body.append(notesSheet);
|
document.body.append(notesSheet);
|
||||||
epScreen = {e, ctx, actions, playBtn, box, facts: info.querySelector('.nui-pod-epfacts')};
|
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
|
||||||
episodeScreenParts();
|
episodeScreenParts();
|
||||||
showDetails(box, e.id, false, seconds => playAt(e, seconds));
|
showDetails(box, e.id, false, seconds => playAt(e, seconds));
|
||||||
}
|
}
|
||||||
|
|
@ -5199,9 +4613,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
if (N.podcastDiscard) {
|
if (N.podcastDiscard) {
|
||||||
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
|
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;
|
const fav = e.raw.favorite;
|
||||||
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
|
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
|
||||||
fav ? 'on' : '');
|
fav ? 'on' : '');
|
||||||
|
|
@ -6417,7 +5828,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
||||||
window.__dioraBack = function () {
|
window.__dioraBack = function () {
|
||||||
if (closeStats()) return true;
|
if (closeStats()) return true;
|
||||||
if (closeDownloads()) return true;
|
if (closeDownloads()) return true;
|
||||||
if (closeTrSettings()) return true;
|
|
||||||
if (closeNotes()) return true;
|
if (closeNotes()) return true;
|
||||||
if (closePlayer()) return true;
|
if (closePlayer()) return true;
|
||||||
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
|
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* Copied from diora-android 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css
|
/* Copied from diora-android 5227c26 2026-10-26: the podcast parts of web-native/static/native.css
|
||||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||||
The web's own additions are in podcasts-web.css. */
|
The web's own additions are in podcasts-web.css. */
|
||||||
|
|
||||||
|
|
@ -550,21 +550,6 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
|
||||||
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
|
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
|
||||||
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
|
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
|
||||||
.nui-pod-epact-danger { color: var(--red, #e5484d); }
|
.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 { border-color: #000; color: #000; }
|
||||||
body.eink .nui-pod-epact-danger { color: #000; }
|
body.eink .nui-pod-epact-danger { color: #000; }
|
||||||
body.eink .nui-pod-chapter.current,
|
body.eink .nui-pod-chapter.current,
|
||||||
|
|
|
||||||
|
|
@ -400,8 +400,7 @@
|
||||||
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
||||||
<script src="/static/js/app/99-init.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. #}
|
{% 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>
|
<script type="module" src="/static/js/ai.js"></script>{% endif %}
|
||||||
<script type="module" src="/static/js/podcast-article.js"></script>{% endif %}
|
|
||||||
<script src="/static/js/podcasts-web.js"></script>
|
<script src="/static/js/podcasts-web.js"></script>
|
||||||
<script src="/static/js/home.js"></script>
|
<script src="/static/js/home.js"></script>
|
||||||
<script src="/static/js/feeds.js"></script>
|
<script src="/static/js/feeds.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
// 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 & 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('<script>') && evil.includes('<strong><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 & gut.</p>\n<h2>Erste <b>Idee</b></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);
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue