PRD: Kurageyomi — sentence mining from Japanese Jellyfin subtitles #1
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
mobrule/kurageyomi#1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Kurageyomi — sentence mining from Japanese Jellyfin subtitles
Problem Statement
I watch Japanese shows on my own Jellyfin server to study the language, and the words I most
want to learn are the ones I meet while watching. Right now there is no way to act on them in
the moment. If I want a word, I have to stop the episode, get to another device, retype
something I only half-heard into a dictionary, guess at its dictionary form, and then rebuild
the sentence and the moment by hand into an Anki card. It is enough friction that I mostly
don't bother, so the words I encounter while watching are exactly the words I never learn.
Existing sentence-mining setups solve this for local video players, but my library lives on a
Jellyfin server and I watch it on whatever screen is convenient. Nothing bridges those.
Solution
A Jellyfin plugin that turns my phone into a mining surface for whatever is currently playing.
The plugin serves a web page over the tailnet. When an episode is playing on any of my
clients, the phone shows the Japanese Cue currently on screen, following the Playhead of that
Playback Session. Tapping an unknown word pauses playback, runs a Yomichan-style lookup —
deinflecting the Surface Form to a Headword and showing the dictionary entry — and saves it as
a Capture with the whole Cue for context. Dismissing the lookup resumes the show.
Captures accumulate in a Mining Queue that lives on the server, so mining never depends on
Anki being open or reachable. Later, an Export takes the whole queue, cuts an audio Clip and a
still Frame for each Capture straight from the source file with ffmpeg, and pushes finished
Notes into Anki over the tailnet.
The result: seeing an unknown word and having a card with audio, an image, the sentence, and a
definition becomes one tap, taken without leaving the sofa.
User Stories
read along with what I am hearing.
not configure anything before starting an episode.
that the phone follows the right screen.
the common case needs no interaction.
position reports, so that the text on my phone matches the audio rather than lagging behind
it.
jump in the show does not leave stale text in my hand.
I am not unlocking it every thirty seconds.
that tapping the first character of a compound gives me the compound.
食べさせられなかった finds 食べる.
why the word looks the way it does on screen.
can choose the sense that actually fits the scene.
does not run on while I read.
episode costs nothing.
does not fill my deck.
stays fast enough to do repeatedly in a single scene.
later see the word in the shape the show used.
the word is what I study.
later dictionary update cannot silently change what I learned.
Clips and Frames can be cut later and so I can find the scene again.
not build duplicate cards for the same word across episodes.
never blocked by something being unreachable.
that I have a fallback when a line is grammatically rather than lexically hard.
instead of the Japanese.
I am not stuck with signs-and-songs or a commentary track.
read as text rather than as markup.
so that a styled subtitle does not become unreadable.
understand why mining is unavailable instead of seeing a blank screen.
session's work is not one tap from being lost.
is worth doing.
choose rather than as a side effect of watching.
listening and not just reading.
the scene it came from.
audio does not clip the first or last mora.
a note type in Anki before I can use the app.
do not duplicate cards.
failure is visible rather than silent.
up rather than losing them.
not force me to reason about what already landed.
a dense scene does not put ffmpeg jobs on a server that is already transcoding for me.
EDRDG licence is honoured.
not depend on anything but the phone.
rather than a browser tab.
account or token to manage.
Export goes somewhere I chose.
failure is predicted rather than discovered.
that a study tool never degrades the media server for anyone else using it.
Implementation Decisions
Everything below is settled by the design session recorded in
CONTEXT.mdand ADRs 0001–0004.Shape
The whole app is a Jellyfin plugin (ADR 0001). It serves the phone UI as a plugin web asset,
same-origin, authenticated by the Jellyfin token the phone already holds. No sidecar service,
no second auth system, no CORS.
The phone side is an installable PWA, not a native shell. A Tauri wrapper was considered and
deferred: it would re-introduce the seams the plugin decision removes (bundled assets, its own
token) to buy protection against risks — chiefly IndexedDB eviction — that may never
materialise. Wrapping a working PWA later is cheap.
Target Jellyfin 10.11 (
dionysusruns 10.11.11), not the 10.9 the official plugin templatepins. Jellyfin runs in a container, so ffmpeg comes from the server's own media encoder and
item paths are already container-relative.
Division of labour
C# handles sessions, media, storage, and export. The browser handles all Japanese. This split
is deliberate — the mature deinflection code is TypeScript, and there is no reason to port it.
Plugin modules
with styling, positioning, and ruby markup removed. Jellyfin types do not appear at its
boundary. Text tracks only.
Playhead, item, and stream indices.
list, drain, and the Known Snapshot. Plugin configuration is XML and is explicitly not used
for this.
encoder is injected; the module's real logic is argument construction — padding, seek
strategy, and where in the Cue the Frame is taken.
duplicate checking, and partial-failure reporting.
Phone modules
10ten Japanese Reader (GPL-3.0) along with its existing tests, rather than written fresh.
jmdict-engbuild bundled as a plugin asset, loaded once intoIndexedDB, queried by longest match from a tap offset. No morphological tokenizer exists in
this system (ADR 0002); lookup is tap-driven longest-match plus deinflection, which is why
none is needed.
state into a current position, and from that selects the current Cue.
playback-state events.
Contracts and behaviour
report immediately on pause, unpause, and any seek beyond a ~5-second drift. The phone
therefore interpolates between reports and resyncs on every push; it must never assume a
report is fresh.
unpause. Both produce an immediate progress report, which resyncs the Playhead as a side
effect.
media-source identity, the chosen dictionary entry as it read at capture time, and the
deinflection reason chain.
fixed note type is created on first Export — the Anki collection is starting empty, so there
is nothing to map onto and no migration to consider.
so collection-wide indexing buys nothing yet; it is an additive change later if this is ever
shared.
dex-winover the tailnet(ADR 0003). Because it is not a browser call, CORS never applies.
with their error, and a second run retries only those.
Security
AnkiConnect ships unauthenticated (
apiKeydefaults to none) and bound to loopback. Reachingit from the plugin requires binding it beyond loopback — bind specifically to the Tailscale
interface address, never
0.0.0.0, and set anapiKey. AnkiConnect can read and modify theentire collection; tailnet plus apiKey is acceptable, LAN or public is not.
Licensing
GPL-3.0, forced by the lifted deinflection table and consistent with Jellyfin's own GPL-3.0
plugin template (ADR 0004). JMdict's EDRDG licence requires visible attribution, which is a
product requirement rendered in the lookup UI, not a README line.
Testing Decisions
A good test here asserts external behaviour at a module's boundary — text in, Cues out;
Captures in, Anki calls out — and never reaches for internals. Tests should survive a rewrite
of a module's insides. Fakes stand in for Jellyfin's encoder and for AnkiConnect; nothing in
the suite requires a running Jellyfin, a running Anki, or a real media file beyond small
fixtures.
There is no prior art: this is a greenfield repo. Per standing practice, new logic gets tests
regardless of repo precedent, so the suite starts with this work rather than being retrofitted.
Tested — the six deep modules:
overlapping cues, and image-track rejection.
restart, and behaviour when draining an empty queue.
boundaries, seek behaviour, Frame position within the Cue, and handling of a Cue that starts
at zero or runs to the end of the file.
duplicate skipping, partial failure leaving the right Captures queued, and a repeated run
being a no-op.
behaviour at the end of a Cue, and lookups that match nothing.
arriving mid-interpolation, cue selection at exact boundaries, and a stale report that never
gets superseded.
Deinflector arrives with its own test suite from 10ten; those tests come across with the
code rather than being rewritten.
Not tested: SessionWatcher, PlaybackRemote, and SessionFeed. All three are thin pass-throughs
to Jellyfin APIs where a test would assert the mock rather than any behaviour of ours. If
server upgrades start breaking them — a live risk given the rebuild-per-major consequence in
ADR 0001 — that judgement gets revisited.
Out of Scope
only image tracks get an explanatory message.
absence of a tokenizer possible (ADR 0002). Noticing your own gaps is the study activity.
model of what the learner knows.
mistakes get deleted.
enforces on sessions.
the
jellyfin-plugin-prefix and the EDRDG commercial-use restriction needs a second look.Further Notes
The riskiest assumption in the whole design is that a 10-second position report plus
client-side interpolation feels tight enough to read along with. Everything else is
conventional work; that one is a feel question no amount of planning settles. The cheapest
possible slice — plugin skeleton, one live Cue rendering on the phone, no dictionary, no
mining — tests it, and should come first.
Open questions deliberately left for implementation, none of them architectural: how much
padding a Clip gets and whether it may extend into an adjacent Cue; exactly where in a Cue the
Frame is taken; how English tracks are chosen when a show has several; and how partial Export
failures are surfaced in the UI.
dionysuscurrently reports a Tailscale DNS health warning. Worth ruling out before debuggingany connectivity problem between the plugin and
dex-win.Slice order, decided 2026-08-10. Lookup comes before mining, as two separate slices rather than the one the PRD implies.
Next slice — lookup only. Tap a word in the transcript, deinflect the Surface Form, look the Headword up in JMdict, show the entry. Nothing is saved. Brings in the full
jmdict-engbundled as a plugin asset, IndexedDB on the phone, the deinflection table lifted from 10ten (GPL-3.0, ADR 0004), and Yomichan-style longest-match from the tap point — no tokenizer (ADR 0002). Also the auto-pause on tap, since reading a definition while the show runs on is the problem it solves.Slice after — mining. Capture, the Mining Queue in SQLite, the Known Snapshot duplicate badge, and Export to Anki with Clips and Frames (ADR 0003).
Why split: lookup is useful on its own, and it defers all the storage and export work behind the question of whether tap-to-lookup actually feels right on a phone. Same reasoning that made the first slice stop at 'does reading along work' — which paid off, since it did.
Nothing in the ADRs changes. This only reorders what gets built.