Help us shape the future of Go! Take the 2026 Go Developer Survey

subtitles

package
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: GPL-3.0 Imports: 35 Imported by: 0

Documentation

Overview

Package subtitles fetches missing subtitle languages from OpenSubtitles and serves them as external (sidecar) tracks.

Flow: Sidecars is called with the tracks embedded in the chosen release; for each of the user's preferred languages not already embedded it searches OpenSubtitles, ranks candidates (trusted uploaders, download count, release-name match — a moviehash match is impossible before the file exists), downloads the best, normalises it to UTF-8 SRT + WebVTT and caches both on disk. The returned core.StreamInfo carries an ExternalURL of the form "ether-subtitle:<id>"; the Plex front end resolves it with Open / OpenFormat.

Index

Constants

View Source
const URLPrefix = "ether-subtitle:"

URLPrefix prefixes the ExternalURL of sidecar streams from this package.

Variables

View Source
var ErrQuota = errors.New("opensubtitles: download quota exhausted")

ErrQuota is returned when OpenSubtitles refuses a download because the account's (or anonymous IP's) daily download quota is used up.

Functions

func AcceptsUploadFormat added in v0.3.0

func AcceptsUploadFormat(format string) bool

AcceptsUploadFormat is the Plex Web upload format check: case-sensitive on the wire. PMS accepts srt/ass/ssa/smi/txt/psb; Ether also accepts vtt.

func HasMojibake added in v0.3.0

func HasMojibake(s string) bool

HasMojibake reports UTF-8 that still looks like a 1252 mis-decode. Only the two-byte pair patterns count: a single Å/Ä/Ã is a real Nordic or German letter, not mojibake.

func MissingLanguages

func MissingLanguages(user *core.User, embedded []core.StreamInfo) []string

MissingLanguages returns the user's preferred languages (in preference order) that no embedded subtitle track provides.

func MovieHash

func MovieHash(r io.ReaderAt, size int64) (string, error)

MovieHash reads exactly the first and last 64 KiB. Call only on cached source ranges so hashing does not fetch a release during first-frame startup. Implemented from the published OpenSubtitles algorithm, without copied code.

func Normalize

func Normalize(raw []byte) (srt, vtt string, err error)

Normalize converts raw downloaded subtitle bytes to UTF-8 SRT and WebVTT, guessing a legacy encoding as Windows-1252.

func NormalizeDialogue

func NormalizeDialogue(s string) string

NormalizeDialogue reduces cue text to a comparison key: tags and hearing-impaired annotations dropped, punctuation and case folded away, so OCR/punctuation variants of the same line compare near-equal.

func NormalizeLang

func NormalizeLang(raw []byte, lang string) (srt, vtt string, err error)

NormalizeLang is Normalize with the subtitle's language, which picks the legacy code page for non-UTF files (Windows-1251 for Russian, …).

func Rank

func Rank(cands []Candidate, releaseName string)

Rank scores candidates in place, best first. There is no file yet, so no moviehash: download count (log-scaled), trusted uploaders, ratings and release-name token overlap carry the signal; AI-translated and hearing-impaired tracks are penalised (HI only slightly — sometimes it is the only option).

func RenderSRT

func RenderSRT(cues []Cue) string

RenderSRT renders cues as SubRip.

func RenderVTT

func RenderVTT(cues []Cue) string

RenderVTT renders cues as WebVTT.

func RepairMojibake added in v0.3.0

func RepairMojibake(s string) string

RepairMojibake undoes UTF-8 that was read as Windows-1252 or Latin-1 and written back as UTF-8. Unchanged when the reverse is not valid UTF-8 or does not look cleaner.

func SourceKey added in v0.3.0

func SourceKey(raw string) string

SourceKey is the on-disk correction key for a release/source id. URLs and path-like values are hashed so they never become path components. Store and load both go through this function.

func Test

func Test(ctx context.Context, cfg config.OpenSubtitles) error

Test checks the owner's OpenSubtitles settings: with account credentials it logs in (which also validates the API key); otherwise it makes one API-key-only request.

Types

type AlignOptions

type AlignOptions struct {
	MinMatched     int           // default 10
	MaxResidual    time.Duration // median bound, default 500ms
	MaxResidualP90 time.Duration // default 1s
}

AlignOptions bounds what counts as a confident alignment.

type AlignSegment

type AlignSegment struct {
	FromStart time.Duration `json:"fromStart"`
	FromEnd   time.Duration `json:"fromEnd"`
	Slope     float64       `json:"slope"`
	Intercept time.Duration `json:"intercept"`
	Matched   int           `json:"matched"`
}

AlignSegment is one affine run: To = Slope*From + Intercept (millisecond space), valid for From times in [FromStart, FromEnd].

type Aligner

type Aligner struct {
	// contains filtered or unexported fields
}

Aligner implements core.TimelineAligner: it maps a moment in one release of a title to the same moment in another release using only subtitle dialogue timing, so no video is ever downloaded. Timelines come from subtitles already cached on disk or from OpenSubtitles files matched to each release by name (or, when only a runtime is known, by how close the file's last cue lands to that runtime). Everything is off unless the OpenSubtitles service is enabled.

Failover calls AlignTime with a sub-second deadline: on a cache miss that returns ok=false immediately and warms the cache in the background. Callers with time to spare (marker shifting) get a synchronous build.

func (*Aligner) AlignTime

func (a *Aligner) AlignTime(ctx context.Context, req core.AlignRequest, at time.Duration) (time.Duration, bool)

AlignTime implements core.TimelineAligner.

func (*Aligner) Prewarm

func (a *Aligner) Prewarm(req core.AlignRequest)

Prewarm starts a background alignment build for a release pair so a later failover or marker lookup hits the cache. Duplicate and recently-failed pairs are skipped.

type CachedSubtitle

type CachedSubtitle struct {
	ID        string    `json:"id"`
	Language  string    `json:"language"`
	Release   string    `json:"release"`
	FetchedAt time.Time `json:"fetchedAt"`
	Key       string    `json:"-"`
}

CachedSubtitle identifies a sidecar from its own metadata. Key is internal only; the app resolves it through the catalog. Legacy metadata without a key stays unknown, with no selection-index scan on the list request path.

type Candidate

type Candidate struct {
	FileID        int64
	FileName      string
	Language      string
	Release       string
	DownloadCount int
	Ratings       float64
	Uploader      string
	UploaderRank  string
	HearingImp    bool
	AITranslated  bool
	HashMatch     bool
	// Forced marks a real forced track: OpenSubtitles'
	// foreign_parts_only and NOT hearing_impaired (a HI
	// foreign-parts track is SDH, not forced; HearingImp
	// carries that signal separately).
	Forced bool
	Score  float64 // filled by Rank
}

Candidate is one OpenSubtitles search result.

type Cue

type Cue struct {
	Start, End time.Duration
	Text       string
}

Cue is one subtitle cue.

func ParseASS

func ParseASS(text string) ([]Cue, error)

ParseASS converts Dialogue events using the declared Format field. Styling, positioning and karaoke effects are deliberately dropped for text fallbacks.

func ParseCues

func ParseCues(text string) ([]Cue, error)

ParseCues parses SRT or WebVTT text (already UTF-8) into cues.

func ReadEmbeddedTextCues added in v0.3.0

func ReadEmbeddedTextCues(ctx context.Context, src io.ReaderAt, size int64) ([]Cue, error)

ReadEmbeddedTextCues reads cue times from the first embedded TEXT subtitle track of a Matroska or MP4 source, using only the container index and bounded range reads.

func Shift

func Shift(cues []Cue, offset time.Duration, rate float64) ([]Cue, error)

Shift applies an offset and playback-rate correction without mutating input. Negative cues are dropped or clipped, preserving valid SRT/WebVTT times.

type FitOptions added in v0.3.0

type FitOptions struct {
	MinInliers    int
	MinConfidence float64
	MaxOffset     time.Duration
	FrameSlack    time.Duration
}

FitOptions tunes FitTiming. Zero values use the defaults above.

type Options

type Options struct {
	Config     config.OpenSubtitles
	CacheDir   string // data dir subfolder for downloaded subtitles
	BaseURL    string // override for tests
	HTTPClient *http.Client
	Logger     *slog.Logger
	UserAgent  string // OpenSubtitles requires an app User-Agent
	// NegativeTTL is how long "no subtitle found" for a title+language is
	// remembered before searching again (default 6h).
	NegativeTTL time.Duration
	// MaxRetryAfter caps a 429 Retry-After wait (default 3s).
	MaxRetryAfter time.Duration
	Now           func() time.Time
	// AlignLanguages are tried in order for dialogue timeline alignment
	// (default ["en"]: the most widely subtitled language).
	AlignLanguages []string
	// FFmpeg is the owner-configured ffmpeg used for speech-energy
	// measurement. Empty skips that path.
	FFmpeg string
}

Options construct a Service. Config is a snapshot; call UpdateConfig on config changes if desired.

type Piecewise

type Piecewise struct {
	Segments []AlignSegment `json:"segments"`
	Matched  int            `json:"matched"`
	// Residual is the median absolute error of matched cues against the fit.
	Residual time.Duration `json:"residual"`
}

Piecewise is a confident dialogue-based mapping between two releases.

func AlignPiecewise

func AlignPiecewise(from, to []Cue, opts AlignOptions) (*Piecewise, error)

AlignPiecewise builds the mapping from the "from" cue timeline to the "to" cue timeline, or fails when there is no confident alignment (too few matched lines, or matched lines that do not fit within the residual bound — typically a different language or an unrelated subtitle file).

func (*Piecewise) Map

func (p *Piecewise) Map(at time.Duration) (time.Duration, bool)

Map converts a timestamp in the "from" timeline to the "to" timeline. Times before the first (or after the last) matched cue extrapolate the nearest segment, which is how an intro before the first line of dialogue still shifts by the logo-length difference.

type Preferences added in v0.2.34

type Preferences struct {
	Mode  string // off, forced-only, foreign-only, always
	Style string // normal, sdh, forced
}

Preferences is the viewer's subtitle mode/style (trackprefs vocabulary) for automatic fetching. Empty means no preference: best-ranked wins.

type ReleaseAccess added in v0.3.0

type ReleaseAccess struct {
	Reader   io.ReaderAt
	Size     int64
	URL      string
	Duration time.Duration
	Info     *core.MediaInfo
	Source   string
	FFmpeg   string
	Done     func()
	Hold     func() (io.ReaderAt, int64, func(), bool)
}

ReleaseAccess is enough of a playing session to measure a sidecar. Reader and URL must belong to that session's lifetime; Enrich must not pass a prepare-generation handle. Hold, when set, is taken after the speech semaphore so a retired source can close during a failover.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the OpenSubtitles client + disk cache.

func New

func New(opts Options) *Service

New builds a Service. A disabled service returns no sidecars.

func (*Service) CachedSidecars added in v0.2.21

func (s *Service) CachedSidecars(title *core.Title, user *core.User, embedded []core.StreamInfo, prefs ...Preferences) []core.StreamInfo

CachedSidecars lists, without any network call, the sidecars already on disk for a title: viewer-picked tracks, then for each of the viewer's preferred languages missing from embedded the most recently fetched track (whichever release it was matched to). Streams whose ExternalURL is already in embedded are skipped, so it can top up a session's enriched MediaInfo too. Nil when the service is disabled.

func (*Service) CachedSubtitles

func (s *Service) CachedSubtitles() ([]CachedSubtitle, error)

func (*Service) CuesForInfo

func (s *Service) CuesForInfo(info core.MediaInfo) ([]Cue, bool)

CuesForInfo returns dialogue cues for the release described by info when the system already holds their text: a cached sidecar stream in info.Streams. It never fetches video and never touches the network. Embedded tracks whose text was never extracted are not available here.

func (*Service) DialogueTimeline

func (s *Service) DialogueTimeline(ctx context.Context, title *core.Title, lang, releaseName string, dur time.Duration) ([]Cue, bool)

DialogueTimeline returns the best dialogue timeline for one release (cached sidecar first, then OpenSubtitles), without downloading video.

func (*Service) DownloadOnDemand added in v0.2.21

func (s *Service) DownloadOnDemand(ctx context.Context, title *core.Title, fileID int64) (core.StreamInfo, error)

DownloadOnDemand fetches a file a recent SearchOnDemand offered for the title, remembers it as the title's pick for its language (replacing any earlier pick) and returns its sidecar stream. core.ErrNotFound means the file was not offered for this title (or the offer expired).

func (*Service) Enabled

func (s *Service) Enabled() bool

Enabled reports whether sidecar fetching is configured.

func (*Service) Enrich

func (s *Service) Enrich(ctx context.Context, title *core.Title, user *core.User, res *core.Resolution, src core.MediaSource, info *core.MediaInfo, prefs ...Preferences)

Enrich is playback.Deps.AugmentMedia. It attaches sidecars already on disk for this release. It does not search or download: a file is fetched only when the viewer picks one. The source is exclusively owned by the preparing generation, so local hashing cannot race disposal during failover.

func (*Service) ManualSidecars added in v0.3.0

func (s *Service) ManualSidecars(title *core.Title, embedded []core.StreamInfo) []core.StreamInfo

ManualSidecars lists viewer-picked tracks already on disk. Unlike CachedSidecars it does not require OpenSubtitles to be configured, so a translated or transcribed track still appears when that account is off.

func (*Service) Open

func (s *Service) Open(id string) (io.ReadCloser, string, error)

Open resolves a sidecar id (or a full ExternalURL) to its SRT content. It returns the reader and the format ("srt").

func (*Service) OpenFormat

func (s *Service) OpenFormat(id, format string) (io.ReadCloser, string, error)

OpenFormat resolves a sidecar id in the requested format ("srt" or "vtt").

func (*Service) OpenFormatAt added in v0.2.21

func (s *Service) OpenFormatAt(id, format string, offset time.Duration) (io.ReadCloser, string, error)

OpenFormatAt is OpenFormat with the cue times moved back by offset, for a transcode session whose timeline starts offset into the title. Cues that end before the offset are dropped.

func (*Service) OpenFormatAtFrom added in v0.3.0

func (s *Service) OpenFormatAtFrom(id, format string, offset time.Duration, source string) (io.ReadCloser, string, error)

OpenFormatAtFrom is OpenFormatAt for a known release/source.

func (*Service) OpenFormatFrom added in v0.3.0

func (s *Service) OpenFormatFrom(id, format, source string) (io.ReadCloser, string, error)

OpenFormatFrom is OpenFormat applying a cached fit only for this release/source. An empty source serves the original sidecar.

func (*Service) Search

func (s *Service) Search(ctx context.Context, title *core.Title, lang, releaseName string) ([]Candidate, error)

Search returns ranked candidates for a title and language.

func (*Service) SearchOnDemand added in v0.2.21

func (s *Service) SearchOnDemand(ctx context.Context, title *core.Title, lang, releaseName string) ([]Candidate, error)

SearchOnDemand is the viewer-driven search behind Plex's subtitle "Search": ranked OpenSubtitles candidates for one language (releaseName, when a session knows it, improves the ranking). Results are remembered for an hour so DownloadOnDemand only fetches files offered for the title.

func (*Service) SearchWithHash

func (s *Service) SearchWithHash(ctx context.Context, title *core.Title, lang, releaseName, movieHash string) ([]Candidate, error)

SearchWithHash prefers exact cached-source matches; empty hash uses release ranking.

func (*Service) SetFFmpeg added in v0.3.0

func (s *Service) SetFFmpeg(path string)

func (*Service) Sidecars

func (s *Service) Sidecars(ctx context.Context, title *core.Title, user *core.User, embedded []core.StreamInfo, prefs ...Preferences) ([]core.StreamInfo, error)

Sidecars returns external subtitle streams already cached for the user's preferred languages missing from the embedded tracks. It never searches or downloads; a file is fetched only when the viewer picks one (DownloadOnDemand). It returns (nil, nil) when the service is disabled or nothing cached is missing.

func (*Service) SidecarsForRelease

func (s *Service) SidecarsForRelease(ctx context.Context, title *core.Title, user *core.User, embedded []core.StreamInfo, releaseName string, prefs ...Preferences) ([]core.StreamInfo, error)

SidecarsForRelease is Sidecars with the chosen release's name, so a selection remembered for that release is the one listed. It does not contact OpenSubtitles.

func (*Service) SidecarsWithHash

func (s *Service) SidecarsWithHash(ctx context.Context, title *core.Title, user *core.User, embedded []core.StreamInfo, releaseName, movieHash string, prefs ...Preferences) ([]core.StreamInfo, error)

SidecarsWithHash uses an already-computed cached-source hash for exact release matching.

func (*Service) SourceText added in v0.3.0

func (s *Service) SourceText(key core.RatingKey, except string) (lang, text string, ok bool)

SourceText returns one cached subtitle in a language other than except, preferring English. It reads only files already on disk.

func (*Service) StartCorrection added in v0.3.0

func (s *Service) StartCorrection(ctx context.Context, title *core.Title, id string, access ReleaseAccess)

StartCorrection measures the cached sidecar against the live session's source. It no-ops without a source handle (no prefetch). The first serve after a pick is uncorrected unless this measurement finishes before OpenFormatFrom's short wait.

func (*Service) StoreText added in v0.3.0

func (s *Service) StoreText(title *core.Title, lang, label, text string, fileID int64) (core.StreamInfo, error)

StoreText saves subtitle text the server produced (a translation or a transcript) and remembers it as the viewer's pick for that language. A later call with the same title, language and file ID returns the cached track and does not rewrite it. It never contacts a provider.

func (*Service) StoreUpload added in v0.3.0

func (s *Service) StoreUpload(title *core.Title, filename, format, language string, body []byte) (core.StreamInfo, error)

StoreUpload saves a viewer-uploaded sidecar as UTF-8 SRT+VTT. It is listed as an upload-sig track and does not overwrite another viewer's manual pick.

func (*Service) StreamByFileID added in v0.3.0

func (s *Service) StreamByFileID(title *core.Title, fileID int64) (core.StreamInfo, bool)

StreamByFileID returns a generated or picked sidecar already on disk. It does not download anything.

func (*Service) SyncHandler

func (s *Service) SyncHandler() http.Handler

SyncHandler serves GET / of cached sidecars and POST /{id} as a manual timing adjust written into the correction store.

func (*Service) TimelineAligner

func (s *Service) TimelineAligner() *Aligner

TimelineAligner returns the service's (single) core.TimelineAligner. Wiring is one line each for markers and playback:

markers.New(markers.Options{..., Aligner: subs.TimelineAligner()})
playback.Deps{..., Aligner: subs.TimelineAligner()}

func (*Service) UpdateConfig

func (s *Service) UpdateConfig(cfg config.OpenSubtitles)

UpdateConfig swaps the OpenSubtitles settings (e.g. from config.Store.Subscribe).

type SpeechWindow added in v0.3.0

type SpeechWindow struct {
	Start time.Duration
	Rate  int     // samples per second
	PCM   []int16 // mono
}

SpeechWindow is one decoded audio slice used as a timing reference.

func DecodeAudioWindows added in v0.3.0

func DecodeAudioWindows(ctx context.Context, ffmpegPath, input string, duration time.Duration, windows int, span time.Duration) ([]SpeechWindow, error)

DecodeAudioWindows reads a few short windows of the first audio track with ffmpeg from a local file or HTTP URL. Skipped when ffmpeg is absent.

func DecodeAudioWindowsReader added in v0.3.0

func DecodeAudioWindowsReader(ctx context.Context, ffmpegPath string, r io.ReaderAt, size int64, duration time.Duration, windows int, span time.Duration) ([]SpeechWindow, error)

DecodeAudioWindowsReader is DecodeAudioWindows from a held source reader.

type TimingFit added in v0.3.0

type TimingFit struct {
	Offset     time.Duration
	Rate       float64
	Confidence float64
	Inliers    int
	Residual   time.Duration
}

TimingFit is a constant offset plus a frame-rate scale that maps candidate cue times onto a reference clock:

ref ≈ candidate*Rate + Offset

func FitSpeechEnergy added in v0.3.0

func FitSpeechEnergy(cues []Cue, windows []SpeechWindow) (TimingFit, bool)

FitSpeechEnergy cross-correlates the subtitle on/off mask with a band-passed energy detector over a few short audio windows.

func FitSpeechEnergyOptions added in v0.3.0

func FitSpeechEnergyOptions(cues []Cue, windows []SpeechWindow, opt FitOptions) (TimingFit, bool)

FitSpeechEnergyOptions is FitSpeechEnergy with explicit thresholds.

func FitTiming added in v0.3.0

func FitTiming(candidate, reference []Cue) (TimingFit, bool)

FitTiming aligns candidate cue start times to reference start times. Matching is language-independent: only the timing pattern is used.

func FitTimingOptions added in v0.3.0

func FitTimingOptions(candidate, reference []Cue, opt FitOptions) (TimingFit, bool)

FitTimingOptions is FitTiming with explicit thresholds (tests use a smaller cue count).

func (TimingFit) Identity added in v0.3.0

func (f TimingFit) Identity() bool

Identity reports a fit that leaves the sidecar unchanged.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL