subtitles

package
v0.2.39 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: GPL-3.0 Imports: 33 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 AlignAgainst

func AlignAgainst(cues, reference []Cue) (time.Duration, error)

AlignAgainst estimates a constant offset using exact normalized text matches against embedded reference subtitles. It requires several independent cues and rejects inconsistent matches rather than guessing from unrelated dialogue.

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 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 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 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
}

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 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. The source is exclusively owned by the preparing generation, so local hashing cannot race disposal during failover. Remote media uses release matching until a cache-only edge reader exists.

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) PrefetchSidecars added in v0.2.21

func (s *Service) PrefetchSidecars(title *core.Title, user *core.User, prefs ...Preferences) bool

PrefetchSidecars fetches, in the background, the viewer's preferred languages a title has no cached sidecar for. The Plex front end calls it when a detail page opens and the playback warm-up (which fetches sidecars for the release it resolves) did not run, so the next metadata or play-queue request can list them. Without a release name the best-ranked track is cached under the release-agnostic signature.

Downloads count against the OpenSubtitles daily quota, so prefetches are budgeted: one per title at a time, two server-wide, twenty an hour, none while the quota is exhausted. False means nothing was started.

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) 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 for the user's preferred languages missing from the embedded tracks. It returns (nil, nil) when the service is disabled or nothing is missing; per-language failures are logged and skipped, never failing playback.

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, used to rank candidates by release-name match (subtitle timing follows the release, so the choice is remembered per release).

Per language: a remembered selection with both files on disk is reused without any network call; otherwise OpenSubtitles is searched, the best three candidates are tried in order, and the winner is cached and remembered. "Nothing found" is remembered for NegativeTTL. A quota error stops downloads for the rest of the call.

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) Sync

func (s *Service) Sync(id string, opts SyncOptions) (string, error)

func (*Service) SyncHandler

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

SyncHandler serves POST /{cachedSubtitleID}; mount behind owner auth/CSRF.

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 SyncOptions

type SyncOptions struct {
	Apply bool `json:"apply"` // apply adjusted copy to the cached release for every viewer

	OffsetMS  int64   `json:"offsetMs"`
	Rate      float64 `json:"rate"`
	Reference string  `json:"reference,omitempty"`
}

SyncOptions adjusts a cached sidecar or estimates the offset from supplied embedded-reference SRT/VTT/ASS. It creates a new cache entry, preserving the original download and every other viewer's selection.

Jump to

Keyboard shortcuts

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