A shared subtitle database for Stash libraries. Someone subtitles a scene once; everyone else who has that scene gets the subtitle β even though their file is a different encode with a different name.
Three pieces: a self-hostable Go + Postgres server that stores subtitle tracks keyed by video fingerprints, a Stash plugin that searches, downloads, and uploads them from the scene page, and MoanDrop β a desktop app (drag-and-drop window + CLI) for people who don't run Stash at all. A public node runs at moansubs.org, and both clients point at it out of the box.
Existing subtitle databases key on titles: you look up "The Matrix (1999)", you get subtitles for The Matrix. That machinery does not work for a Stash library. The content has no IMDb id, filenames are whatever the person who made the file decided, and two people with the same scene usually have two files that share neither name nor byte length.
Stash already computes exactly what's needed to fix that. Every scene
carries an oshash (a cheap file-identity hash) and, once you enable it,
a phash β a perceptual hash of the video's frames. A phash survives
re-encoding: the same scene at a different bitrate, resolution, or
container produces a hash a few bits away from the original, not a
different one. moansubs keys subtitles on those fingerprints, so matching
is on content, not names.
The practical consequence: your library and a stranger's library have almost no filenames in common and essentially zero byte-identical files, but they have the same scenes. That's the overlap moansubs trades on.
A lookup returns candidates with the evidence attached, strongest first. Downloading is always your click β what the level changes is how much the plugin vouches for a candidate before you make it.
| Level | Evidence | Means | Shown as |
|---|---|---|---|
| 0 | stash-box id (StashDB, FansDB, β¦) | The same scene, identified across every encode β ahead of hash matching, because an id is an identity, not a fingerprint | Exact match |
| 1β3 | oshash exact, or phash within Hamming 4 and duration within 1s |
A byte-identical file, or the same content in a different encode | Exact match / Different encode |
| 4 | phash Hamming β€8 with duration within 5s, when not already an exact-level match |
Probably the same content, but timing may drift | Possible match |
| 5 | title/filename token score | This scene has no phash at all β the last-resort fallback | Name match |
Anything from a different encode is flagged sync?, since the timing was cut against someone else's file. Level 4 only appears in full-hash mode, where the wider fuzzy radius is available. Level 5 runs only when hash-based lookup found nothing whatsoever, and stays offer-only regardless of how confident the server is: a server saying CONFIRMED there means "the name evidence is strong", not "this is the same file". Scoring lives in the shared subtitlematch module.
Lookups are bucketed by default. The client sends short hash prefixes and 13-bit hash blocks, gets back candidate buckets, and does the real matching locally β the server never needs your full fingerprints. This is the lookup mechanism, not a privacy guarantee: API.md states exactly what it does and does not leak. Separately, and regardless of that setting, the scene's own stash-box ids (level 0) go along on every lookup β they identify the scene exactly, by design, since that's what makes them stronger evidence than any fingerprint.
ββββββββββββββ bucketed lookup, download, push βββββββββββββββββ
β Stash β βββββββββββββββββββββββββββββββββΊ β moansubs β
β + plugin β βββββββββββββββββββββββββββββββββ β server + DB β
ββββββββββββββ candidates + evidence βββββββββββββββββ
matching happens here ββ
- A Subtitles panel on every scene page: find candidates with their
evidence, see each track's language, license, vote tallies, and an AI
badge if it was machine-generated. Download writes a sidecar
<video stem>.<lang>.srtand triggers the metadata scan that attaches it. - CC badges on scene cards for scenes the server has subtitles for, resolved for a whole wall in one batched call.
- Push: upload one scene's existing sidecars, or run a library-wide task that pushes everything you have.
- Send scene details: tell the server what a scene is β title, date, studio, performers, stash-box ids β without uploading a subtitle. This is the answer to "my library is well curated but I have nothing to contribute here". Your filenames are deliberately never sent, and nothing is sent automatically.
Full details: plugin/README.md.
MoanDrop is the same matching
without Stash: drop a video on a window (or run moandrop match), get
the ranked candidates with the same evidence wording, and one click
writes the .<lang>.srt sidecar that Plex, Jellyfin, Kodi and VLC pick
up. It fingerprints locally β the video never leaves the machine β and
moandrop push shares sidecars back. Binaries, debs/rpms, AUR and
Homebrew installs are on its releases page.
-
Add the package source in Stash (Settings β Plugins β Available Plugins β Add Source) and install moansubs:
Stash's arch Source URL linux/amd64 https://plugins.moansubs.org/plugin/amd64/index.ymllinux/arm64 https://plugins.moansubs.org/plugin/arm64/index.ymlIn the Add Source dialog, enter Name
MoanSubs, the Source URL from the table, and Local pathmoansubs(the subfolder under Stash's plugins directory that this source installs into).The exec half is a native binary and Stash's package source has no notion of architecture, hence one index per arch β match the machine Stash runs on, not the one running the server. The binary is static, so it runs in any Stash container regardless of base image.
-
If moansubs doesn't appear after installing, Settings β Plugins β Reload plugins. Only amd64 and arm64 are published; other platforms build from source (
plugin/README.md). The server URL already defaults tohttps://moansubs.org; leave it alone unless you're running your own. Downloading is anonymous β you need no account at all to start pulling subtitles. -
Enable phash generation (Settings β Tasks β Generate β "Perceptual hashes"). This is the step that matters. Across different people's libraries
oshashalmost never matches β it needs byte-identical files β so phash is what actually finds anything.
To upload or vote, register at moansubs.org/register and paste the token into the plugin's settings. No email required.
To build the plugin from source instead of installing it:
make plugin
mkdir -p /path/to/stash/plugins/moansubs
cp plugin/moansubs.yml plugin/moansubs.js /path/to/stash/plugins/moansubs/
cp plugin/dist/moansubs-plugin-linux-amd64 /path/to/stash/plugins/moansubs/moansubs-plugin
chmod +x /path/to/stash/plugins/moansubs/moansubs-pluginRequirements: Docker with the compose plugin, or Go 1.25+ and Postgres 14+ to run bare.
cp docker-compose.example.yml docker-compose.yml
# edit: set a real POSTGRES_PASSWORD and matching DATABASE_URL
docker compose up -d
curl http://localhost:8080/healthz # -> okThen point the plugin's moansubs server URL setting at it.
Configuration is environment variables, optionally backed by a YAML file
(config.example.yaml is a full commented reference; the environment wins
over it). Accounts are minted from the CLI:
docker compose exec moansubs moansubs account create <name>
docker compose exec moansubs moansubs account listMOANSUBS_REGISTRATION picks who else can get one: open (the default),
invite (a code from an existing member or the operator), or closed
(CLI only). MANUAL.md has every setting, every command, and
the operational notes β backups, rate limits, reverse proxies, the invite
economy.
For a public node, deploy/ is a reference compose stack:
Traefik with auto-TLS in front of the server, Postgres, and an opt-in
nightly backup sidecar (pg_dump | gzip | rclone rcat, 30-day retention).
It's generic β copy it, fill in the placeholders, and follow
deploy/README.md for first boot, upgrades, and a
restore drill.
- Nothing is stored raw. Every upload is parsed and re-rendered, which is what makes sanitization meaningful rather than advisory.
- Machine-generated subtitles are detected, not self-declared. The server looks for Scriptorium provenance markers in the raw bytes before sanitization strips them, and labels the track accordingly no matter what the uploader claimed.
- Timing is sanity-checked against the release's duration; a subtitle whose cues contradict the video is rejected.
- Tracks carry a kind β
default,cc,sdh,forced, orotherwith a short label β naming what a track is, not how good it is. Declared, not enforced: trusted from the uploader (or corrected by a moderator) the same way license is, never forced from detection the way the AI flag above is. - Quality is settled by voting, not curation. Any account can up- or
down-vote a track it didn't upload; a down-vote must pick a reason (out
of sync, wrong content, wrong language, low quality, spam). The resulting
score is the default ordering β human before machine-generated, then by
score, then downloads β so the best-regarded subtitle for a release
surfaces on its own. Enough net downvotes, or a single
spamvote, flags a track for an operator.
See MANUAL.md for the upload pipeline and moderation tools.
- Web catalogue.
/browse,/search,/release/{id}and/u/{name}give the node a browsable face for releases that have name metadata. Kept out of search engines by default (MOANSUBS_INDEXABLE=trueopts in). - Browser upload.
/uploadtakes a subtitle when the plugin isn't handy. Pointing it at the video file computesoshashand probes duration in the browser β the video is never uploaded, and never even read in full, since oshash only needs its first and last 64 KiB. If the browser can decode the container it also approximates the phash the way Stash does; expect a few bits of drift from ffmpeg's value, which is well inside what matching tolerates. A kind dropdown lets you label the subtitle βdefault,cc,sdh,forced, orotherwith a custom label β and the server suggestssdhif it detects bracketed sound cues, musical notes, or speaker labels, a choice you can override. - Stash-box lookups. "Find on stash-box" on
/uploadand a release page fills in title/date/studio/performers/id from a stash-box you have a personal key for β set on/me, one endpoint at a time; the node never holds a key of its own (SECURITY.md). Results are shown for review, never saved silently. An operator can catch a whole corpus up withmoansubs stashbox backfill, using their own key for that one run. - Works. Releases that are the same video cut differently can be grouped, so each offers the others' subtitles with the timing shift needed to fit β the case fingerprinting cannot see, because a trimmed intro moves every frame Stash samples.
- Mirroring.
moansubs dumpwrites every non-withdrawn release and track as gzip JSONL;moansubs importreads it into another node, matching byoshashand skipping what it already has, so re-running is always safe. A mirror inherits the same notice-and-takedown obligations as the node it copies (TAKEDOWN.md). - Contact.
/contactpublishes the operator's address and the takedown procedure; it is a 404 untilMOANSUBS_CONTACT_EMAIL(orMOANSUBS_CONTACT=true) is set, so an unconfigured node never serves a hollow page for crawlers to cache. - Terms and privacy.
/termsand/privacyare always served and linked from every page's footer. The privacy page is rendered from the node's own configuration (age gate, session lifetime, analytics, contact), so a self-hosted node never claims what it doesn't do. Neither names a legal entity, jurisdiction or log retention period; an operator who needs those adds them in a fork. - Removal requests. Every release page carries a per-track, anonymous removal-request form β no account needed, since the rights-holder or the person depicted is exactly the party least likely to have one (TAKEDOWN.md).
Every human page β not the API, not a download β sits behind a one-click
18+ interstitial, with an RTA label in the page head so parental-control
filters can block the site directly. This is a click-through baseline,
not age or identity verification: no ID, no face check, nothing
collected beyond a cookie recording that someone clicked through. An
operator in a jurisdiction that requires real verification needs a
third-party provider in front of this node. MOANSUBS_AGE_GATE=false
turns the interstitial off for an operator handling it another way.
Working v1, running in production at moansubs.org against a real Stash library. The current version is the Release badge above. Server, lookup/upload API, both plugin halves, the web catalogue, and public dumps for mirroring are built and tested. Each release publishes binaries, the container image, the plugin bundles, and the plugin package source.
| docs.moansubs.org | End-user docs: the plugin, the website, kinds, reporting |
| MANUAL.md | Server CLI, configuration, operations |
| API.md | HTTP API and the fingerprint bucket contract |
| plugin/README.md | Plugin install, settings, tasks |
| deploy/README.md | Running a public node |
| CONTRIBUTING.md | Cutting a release, workflows, testing |
| SECURITY.md | Security model and reporting |
| TAKEDOWN.md | Content takedown requests |
Copyright (C) 2026 Wasylq
Code: GPL-3.0-only. Uploaded subtitles: uploaders declare
CC0; mirrored seed
content may carry a different per-track license (recorded in the track's
license/source fields). Node operators handle content complaints via
notice-and-takedown β see TAKEDOWN.md.