Transcribe YouTube-hosted audiovisual items with Google Gemini, straight from their URLs.
Since 2026-08-12 the collection holds embedded YouTube videos alongside its
deposited recordings — sermons, interviews, ceremonies and TV segments ingested
from public channels. They are unsearchable as video, and unlike the deposited
recordings there is nothing to download: a YouTube item's media carries no file
at all (Omeka's youtube ingester stores only thumbnail derivatives, so
o:original_url, o:media_type and o:size are null).
Gemini accepts a YouTube watch URL directly. Google's servers fetch and decode
the video, so this pipeline never touches the stream: no download, no ffmpeg
re-encode, no Files API upload, no local media on disk. That removes most of what
AI_audio_summary/01_omeka_media_downloader.py exists to do, and it is why this
pipeline is genuinely thinner than its sibling rather than a copy of it.
Omeka S (template 23) → work list → Gemini (URL, no download) → bibo:content
└ languages heard → dcterms:language
- Fetch (
01_omeka_youtube_fetcher.py): read items, pull the watch URL offfabio:hasURL, write a work list. Reads metadata only. - Transcribe (
02_AI_transcribe_youtube.py): detect the spoken languages from sampled windows, then transcribe. One request per video, or one per window for videos longer than the budget. - Update (
03_omeka_transcription_updater.py): writebibo:contentwith aniwac:transcriptionModelannotation naming the model. - Correct the record (
04_omeka_language_updater.py, optional): append the languages actually heard todcterms:language.
python 01_omeka_youtube_fetcher.py # item set 108260 by default
python 02_AI_transcribe_youtube.py # prompts for model and prompt
python 03_omeka_transcription_updater.py --dry-run # preview the writes
python 03_omeka_transcription_updater.py --model gemini-3.5-flash-lite
python 04_omeka_language_updater.py --dry-run # preview the language fixesOne item end to end:
python 01_omeka_youtube_fetcher.py --item-id 108353
python 02_AI_transcribe_youtube.py --model gemini-3.5-flash-lite --prompt 1
python 03_omeka_transcription_updater.py --model gemini-3.5-flash-lite --dry-runThe model passed to 03 must be the one that produced the transcripts — it is
what the iwac:transcriptionModel annotation will name. 03 warns when the
transcript headers disagree with it, and when a folder holds output from two
models (one annotation is written for the whole batch, so those need separate runs).
01 defaults to the YouTube item sets in common/iwac_config.py
(YOUTUBE_VIDEO_ITEM_SETS, currently 108260 — YouTube videos Burkina Faso) and
filters to resource template 23. Resource class 38 holds two populations that
behave differently: deposited recordings on template 19, which have real media
files and belong to AI_audio_summary, and embedded YouTube videos on template
23. Filtering by class alone would hand file-less items to a downloader.
Items that already hold bibo:content are skipped. Pass
--include-transcribed to re-transcribe them.
| Flag | Effect |
|---|---|
--item-set-id 108260 |
Read this item set. Repeatable; overrides the default. |
--item-id 108353 |
Read single items. Repeatable; skips the item-set scope. |
--template-id 23 |
Resource template to filter on. |
--all-templates |
Do not filter by template — items are still filtered by whether fabio:hasURL parses as a canonical watch URL. |
--include-transcribed |
Include items that already have bibo:content. |
Only youtube.com/watch?v=<id> and youtu.be/<id> are accepted — the same two
forms Omeka's own ingester accepts. A /shorts/, /live/ or /embed/ link is
reported and skipped rather than sent to the API to fail.
dcterms:language is catalogued per item and is not reliable enough to prompt
from on this material: the collection's 46 YouTube items are all catalogued
Français, and the first one tested is dominated by Mooré. An unprompted
model transcribing Mooré tends to render it as approximate French, which reads as
a clean transcript and is not one.
So 02 makes a cheap first pass before transcribing: two 45-second windows,
sampled at 10% and 55% of the runtime, answered against a small JSON schema. Two
windows rather than one because these videos open on a channel jingle or a French
title card as often as on the speech; ~9k tokens, under 5% of a 33-minute
transcription request.
The detected languages are then named in the transcription prompt, written into
the transcript header, and compared against the catalogue record. A disagreement
is reported per item and collected in Transcriptions/_language_report.json —
a dcterms:language that contradicts the audio is a metadata correction this
pipeline can see and does not make.
Comparison is by ISO code, not by name: the authority labels are French
(Français, Haoussa, Ewé, Mooré) while detection answers in English, so a
naive comparison reports Français against French as a mismatch — which is
exactly what the first live run did before CATALOGUED_LANGUAGE_CODES was added.
python 02_AI_transcribe_youtube.py --no-detect-language # let the model work it out
python 02_AI_transcribe_youtube.py --language French # assert it insteadThe detection pass is evidence about the record, and 04 is what acts on it:
python 04_omeka_language_updater.py --dry-run
python 04_omeka_language_updater.pydcterms:language is a link to an authority item, not a literal, so this writes
resource links through common/omeka_link_updater.py. Three rules shape it:
-
It only ever appends. A catalogued language the samples did not contain is reported for a human to check, never removed. Detection hears 90 seconds; that is enough to prove a language is present and not enough to prove one is absent, and deleting
Françaisfrom an item because two windows happened to be in Mooré would be discarding a curator's judgement on the strength of a sample. -
occasionallanguages are skipped by default — an isolated phrase, a quotation, a line of Qur'anic recitation. Cataloguing Arabic for one bismillah would drown the field it is meant to describe.--include-occasionaloverrides. -
A language with no authority record is reported, not invented. Creating one is a curatorial act:
04prints what is missing, with the item set and template to create it in, and links nothing for it.LANGUAGE_LABELS_BY_CODEincommon/iwac_config.pyis deliberately exactly the labels that exist — adding a code without its record only moves the failure later. Records exist today for Allemand, Anglais, Arabe, Dendi, Dioula, Espagnol, Ewé, Français, Haoussa, Italien, Kabyè, Mooré and Slovène, and not for Peul, Bambara, Zarma, Yoruba or Wolof.Dioula shows the intended loop: the first full run found it on one item,
04reported it as unlinkable, the record was created by hand (item 108359), the code was added to the map, and a re-run linked it. Record first, then code — the map is not the place to express an intention.
Authority items are resolved by title at runtime rather than from a table of IDs: the IDs are assigned per installation and the ones here are scattered (Français 8355, Espagnol 26353, Ewé 66720, Kabyè 79081).
A real example from the first run: item 108309 is catalogued Français, is
dominated by Mooré, and 04 adds the Mooré link while leaving Français in
place and flagging the item for review.
| Model | Notes |
|---|---|
gemini-3.5-flash-lite |
Default. Cheapest and fastest. |
gemini-3.7-flash |
Better on non-French speech in testing. |
Flash-Lite is the default because cost here scales with runtime, the corpus is 9.3 h of video today with more channels to come, and it is catalogued as overwhelmingly French. On French speech the two are close.
They are not close on the local languages, and the gap is not subtle. From the first full-corpus run — 44 videos, 9.1 h, Flash-Lite, zero API failures:
| items | result | |
|---|---|---|
| French-dominant | 36 | all sound; median 156 wpm, worst legitimate 12-gram repeat 7× |
| Mooré-dominant | 8 | 5 sound, 3 degenerate loops — 575×, 1,266× and 2,744× repeats of one clause, each run to the output cap |
Re-running those three on gemini-3.6-flash fixed all three: no looping (1–6×
repeats), 61–85 wpm, on-screen text interleaved and honest [inaudible] markers.
So the failure is model capability on Mooré, not the video or the prompt.
Those runs were on 3.6 Flash, which the 3.7 release replaced here on
2026-08-14; the comparison has not been repeated. Read it as evidence that the
Flash tier handles local languages Flash-Lite cannot — the claim it is used for —
rather than as a 3.7 measurement. The 10 transcripts already on the archive keep
their iwac:transcriptionModel link to item 79611 and go on naming 3.6, which is
what actually produced them.
Naming the language is what makes Flash-Lite attempt Mooré at all — without
detection it renders the speech as French — but attempting is not managing.
The recommended run is therefore hybrid: Flash-Lite over the corpus, then
re-run whatever _language_report.json flags as non-French with
--model gemini-3.7-flash into its own output directory, and upload the two
folders separately so each carries the right iwac:transcriptionModel. Read even
the Flash output as a lead rather than a quotation.
python 02_AI_transcribe_youtube.py --model gemini-3.7-flash \
--work-list work/retry.json --output-dir Transcriptions_flashThe looping failure above is the one this pipeline works hardest to catch, because
nothing else can see it. A model that cannot render a language does not fail
visibly: it emits one plausible clause over and over until it hits the output cap,
producing a file of the right shape, marked Chunks: 1/1, non-empty, and entirely
worthless. Length checks, window-completeness and finish_reason all pass it.
Without a guard it lands in bibo:content as archive full text, is exported to
Hugging Face as OCR, and is indexed for search.
So looping_reason() measures the most repeated 12-word window and rejects the
text above 20 repeats. The threshold sits in a wide observed gap: across 46 real
transcripts the sound ones peaked at 7 repeats (a prayer formula, a station
ident) and the broken ones scored 575 or more. It is enforced twice —
- in
02, where a looping window is discarded and re-drawn, but only twice (LOOP_MAX_ATTEMPTS): a loop runs to the output cap every time, so each retry costs a full 65k-token generation, and it signals a model that cannot render the language rather than an unlucky sample. A window that keeps looping is left out, which makes the transcript incomplete and so ineligible for upload; - in
03, which refuses such a file outright — and unlike incompleteness this is not overridable by--include-incomplete, because a looping transcript is not a partial transcript.
Both are pinned releases rather than the rolling gemini-flash-latest,
because 03 stamps provenance. A rolling alias reports its own version as the
string "Gemini Flash Latest", so a run through one cannot confirm which model
produced the text, and an annotation naming a model the run never confirmed is
provenance in name only. Each key here has an Omeka authority item in
AI_MODEL_ITEMS; a test enforces that.
gemini-flash-latest made the point itself on 2026-08-14: it rolled from 3.6 to
3.7 Flash, and 3.7 had dropped the MINIMAL thinking level every pipeline here
asked for. A run pinned to an id survives that; a run pinned to "whatever is
current" changes model and breaks without anyone choosing either.
No temperature is set. It is vendor-owned, and on a 40-minute transcription a
lowered one is what makes a model loop on a single paragraph for the rest of the
recording.
Video payload costs ~93–103 tokens per second of runtime at the default 1 fps,
measured across four videos of this corpus: 32 tokens of audio plus ~61–71 of
frames. That is the documented low media-resolution rate rather than the ~300/s
default one, so Gemini already serves these YouTube videos at the cheap resolution
and raising media_resolution would only cost more.
| Measured input rate | ~93–103 tok/s at 1 fps (32 audio + ~61–71 frames) |
| Measured output rate | 3.77 tok/s (Flash-Lite), 4.90 tok/s (3.6 Flash), no thinking tokens at minimal |
| Thinking floor | minimal on Flash-Lite; 3.7 Flash has no minimal rung, so it runs at low |
| A 1M context window | ≈ 2.7 hours of video |
Default window (--chunk-minutes) |
45 min, ≈ 280k tokens — a margin, not the limit |
| Longest video in the collection today | 33.6 min |
| Whole corpus (46 videos) | 9.26 h ≈ 3.5–3.9M input + ~0.13M output tokens |
Paid-tier cost for the whole corpus, detection pass included, at the prices current on 2026-08-12 — check them rather than trusting this table:
| model | input | output | corpus |
|---|---|---|---|
gemini-3.5-flash-lite |
$0.30/M | $2.50/M | ≈ $1.45 |
gemini-3.7-flash (to 2026-12-31) |
$0.75/M | $3.75/M | ≈ $3.40 |
gemini-3.7-flash (from 2027-01-01) |
$1.50/M | $7.50/M | ≈ $6.80 |
3.7 Flash is on an introductory price that halves on 2027-01-01, to exactly what 3.6 Flash cost; the corpus figures above are the same tokens at both rates. Its output rate has not been measured here, so the token counts are the 3.6 ones.
Input is ~80% of the bill, so --fps moves the cost far more than anything on the
output side. The hybrid run — Flash-Lite over everything, then Flash on only what
the language report flags as non-French — lands near the Flash-Lite figure.
Both models have a free tier, and the corpus would fit in two free days under
the 8 h/day YouTube cap. Prefer paying: free-tier requests are used to improve
Google's products, and these are whole archival recordings — the same reason every
OpenRouter request in this repo is pinned to data_collection: "deny".
--fps is the lever that lowers this further, and it is a real trade-off:
--fps |
tok/s | 33-min video |
|---|---|---|
| 1.0 (default) | 103.0 | 208k |
| 0.5 | 67.5 | 136k |
| 0.2 | 46.2 | 93k |
Frames are where the on-screen lower-thirds live, and on this material those captions are what name the speakers — the sample transcript attributes turns to "Tené Justine Kientega" and "El hadj Adama Nikiéma" because the prompt reads them off the screen. Drop frames and the transcript loses those names.
The free tier caps YouTube input at 8 hours per day, which the 9.3 h corpus
exceeds; 01 warns when the collected runtime does. Hitting the cap raises
QuotaExhaustedError, which saves what completed and stops rather than retrying a
daily limit as though it were a transient 429.
Videos longer than --chunk-minutes are re-requested at different
VideoMetadata offsets — the same segmentation the audio pipeline does with
ffmpeg, without the download, because the same URL is simply requested at a
different window. Each window after the first re-sends
--chunk-overlap-seconds (default 15) of the previous one, so no utterance falls
in a gap, and the prompt is told two things that are not optional:
- timestamps are absolute positions in the full recording, or every window after
the first restarts at
[00:00:00]and the stitched transcript claims the whole recording happened in its first 45 minutes; - skip any utterance beginning before the nominal boundary, which is how the duplicated overlap is resolved rather than merely moved.
Nothing in the collection triggers this today (the longest video is 33.6 min), so
treat it as tested-by-unit-test rather than proven in production. Existing
deposited extents run to PT571M, so future uploads will exercise it.
02 keeps a checkpoint in Transcriptions/.youtube_transcription_checkpoint.json
whose context pins the model, the prompt hash, the window geometry, the frame rate
and whether language detection ran. Re-running skips videos already transcribed;
changing any of those refuses to resume, because a corpus half-transcribed under
one prompt and half under another has nothing on the outside recording which item
got which. --force replaces the existing output.
A checkpoint entry only counts when its transcript is still on disk.
bibo:content, as one literal, with an iwac:transcriptionModel annotation
(property 315, "AI Model - Transcription"). The property had been declared in the
IWAC vocabulary since it was first uploaded, and this pipeline (2026-08-12) was
the first to write it; AI_audio_summary/03 has stamped it too since
2026-08-27, for the models that have an authority item.
Two deliberate differences from AI_audio_summary/03:
- The file's header is stripped, not uploaded.
bibo:contentis the archive's full-text field, exported to Hugging Face asOCRand indexed for search; "Generated using: Google gemini-3.7-flash" inside it would be indexed as though a speaker had said it. The header stays on disk, where it is auditable. - Transcripts are named
<item_id>.txt, so nodcterms:identifierlookup is needed — one fewer request per file, and no chance of matching the wrong item.
Incomplete transcripts are held back. When a window fails, the header records
Chunks: 2/3 and 03 refuses the file: a transcript missing its middle third is
invisible once it is a single Omeka value. --include-incomplete overrides this.
No failure markers are ever written into the body, for the same reason.
The write goes through common/omeka_text_updater.py: the whole item is fetched
and PATCHed back (Omeka deletes any property missing from the payload), unchanged
items are skipped, and every pre-write payload is appended to backups/ before
its PATCH — the only route back from an overwrite.
python 03_omeka_transcription_updater.py --dry-run # report, write nothing
python 03_omeka_transcription_updater.py --yes # unattended
python 03_omeka_transcription_updater.py --no-backup # not recommended| Prompt | Output |
|---|---|
1_full_video_transcription.md |
Verbatim, in the language spoken, code-switches tagged |
2_video_transcription_french.md |
Translated into French, original language marked per passage |
3_video_transcription_english.md |
Translated into English, original language marked per passage |
All three transcribe on-screen text that carries information the audio does not — speaker captions, place and date cards, banners — and none of them describe the picture otherwise: this is a transcript, not a shot list. Reading the screen is what lets the transcript attribute turns to named people, and it is also where the numbers come from: on the Mooré video the phone numbers and dates are legible on the poster and unintelligible in the model's rendering of the speech.
The editorial apparatus is French in prompt 1, whatever the language spoken —
Locuteur 1 :, [à l'écran : …], [applaudissements], [en mooré]. This is a
francophone archive and the brackets are read by francophone researchers; only the
transcribed speech itself is in the original language. Prompt 3 uses English
markers because its whole output is English.
Select interactively, or with --prompt 1. Edit the files in prompts/; the
prompt's hash is recorded in every transcript header and in the checkpoint, so
editing one invalidates resume rather than silently mixing two versions.
- Public videos only. Gemini cannot fetch a private, unlisted, removed or
region-blocked video; the API answers
400 INVALID_ARGUMENTwith no detail. Those items are reported asunavailableand need a deposited media file and theAI_audio_summarypath instead. This is the one case this pipeline cannot serve at all. - Non-French speech is the hard case, and it is not rare here. All 46 items are
catalogued
Français; the two tested so far are a French one with tagged Mooré passages and a Mooré-dominated one. The transcripts do render Mooré as Mooré, which is the right behaviour, but the orthography is not reliable and both models degrade on sustained local-language speech — Flash-Lite into syllable fragments, 3.6 Flash into approximate romanisation. Treat a non-French passage as a lead, not a quotation, and expect the numbers in it (dates, prices, phone numbers) to be where it fails first. - Nothing verifies the transcript against the audio. Length, timestamps, window completeness and degenerate repetition are checked; accuracy is not. A fluent transcript of a passage the model could not hear looks exactly like a correct one, and the loop guard only catches the failure loud enough to measure.
- The catalogue is wrong about language on ~16% of this set. 8 of 44 items are
Mooré-dominant and all 44 are catalogued
Français. That is worth knowing before treatingdcterms:languageas a filter for anything.
GEMINI_API_KEYin.envOMEKA_BASE_URL,OMEKA_KEY_IDENTITY,OMEKA_KEY_CREDENTIALfor steps 01 and 03
AI_youtube_transcription/
├── work/youtube_videos.json # step 01's work list
├── Transcriptions/
│ ├── 108353.txt # header + transcript
│ ├── _language_report.json # step 04's input: heard vs catalogued
│ └── .youtube_transcription_checkpoint.json
├── backups/ # pre-write Omeka payloads (03 and 04)
└── log/