package store import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" ) // SubtitlePolicyKey is the app_settings row deciding which subtitle providers a household // may fetch from, and holding the credentials for the one that needs them. // // It is an operator setting rather than an environment variable because the two providers // answer different questions and a household changes its mind about them: Bazarr is a // service somebody already runs, OpenSubtitles is an account with a daily allowance. Both // are optional and either can be turned off from the console without a redeployment. const SubtitlePolicyKey = "subtitle_policy" // Subtitle provider identifiers. They travel on the wire — a candidate carries the source // it came from so the download call knows which backend to hand its token back to — so // they are values, not display strings, and must not be renamed. const ( SubtitleProviderBazarr = "bazarr" SubtitleProviderOpenSubtitles = "opensubtitles" // SubtitleProviderMemby is a file the gateway made rather than fetched: a copy of an // existing track with its timing corrected. It is its own provider so the console can // tell a repair from a download, and so clearing the fetched files does not have to // decide what to do with work nobody can re-fetch. SubtitleProviderMemby = "memby" ) // SubtitlePolicy is what the console edits. // // The credentials live in this server-owned document and are never included in a client or // admin status payload — the console is told only whether a key is saved, the same stance // MDBList's takes. type SubtitlePolicy struct { // BazarrEnabled is honoured only where Bazarr is configured at all. A deployment with // no MEMBY_BAZARR_URL has nothing to turn on. BazarrEnabled bool `json:"bazarrEnabled"` OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"` // OpenSubtitlesAPIKey is issued per consumer at opensubtitles.com. Searching needs // only this. OpenSubtitlesAPIKey string `json:"openSubtitlesApiKey"` // The account is optional and buys a download allowance. Searching works without one; // downloading against an anonymous key is quickly exhausted, which is a confusing // failure to meet in front of a television, so the console says so. OpenSubtitlesUsername string `json:"openSubtitlesUsername"` OpenSubtitlesPassword string `json:"openSubtitlesPassword"` UpdatedAt time.Time `json:"updatedAt"` } // DefaultSubtitlePolicy is what an untouched deployment gets: Bazarr on, because until // this document existed a configured Bazarr was already in use and an upgrade must not // quietly take a working feature away, and OpenSubtitles off, because it needs a key // nobody has entered yet. func DefaultSubtitlePolicy() SubtitlePolicy { return SubtitlePolicy{BazarrEnabled: true} } func normalizeSubtitlePolicy(policy SubtitlePolicy) SubtitlePolicy { policy.OpenSubtitlesAPIKey = strings.TrimSpace(policy.OpenSubtitlesAPIKey) policy.OpenSubtitlesUsername = strings.TrimSpace(policy.OpenSubtitlesUsername) // A provider with no key cannot be on, whatever the document says. Storing the // contradiction would leave the console showing a switch that does nothing. if policy.OpenSubtitlesAPIKey == "" { policy.OpenSubtitlesEnabled = false } return policy } func (s *Store) SubtitlePolicy(ctx context.Context) (SubtitlePolicy, error) { var raw []byte err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, SubtitlePolicyKey).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return DefaultSubtitlePolicy(), nil } if err != nil { return DefaultSubtitlePolicy(), fmt.Errorf("store: read subtitle policy: %w", err) } var policy SubtitlePolicy if err := json.Unmarshal(raw, &policy); err != nil { return DefaultSubtitlePolicy(), fmt.Errorf("store: decode subtitle policy: %w", err) } return normalizeSubtitlePolicy(policy), nil } func (s *Store) SetSubtitlePolicy(ctx context.Context, policy SubtitlePolicy) error { policy = normalizeSubtitlePolicy(policy) policy.UpdatedAt = time.Now().UTC() raw, err := json.Marshal(policy) if err != nil { return err } _, err = s.pool.Exec(ctx, ` INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, SubtitlePolicyKey, string(raw)) if err != nil { return fmt.Errorf("store: write subtitle policy: %w", err) } return nil } // ErrSubtitleNotFound means the gateway holds no subtitle under that id. var ErrSubtitleNotFound = errors.New("store: subtitle not found") // DownloadedSubtitle is one subtitle file the gateway fetched and now serves. // // Content is the file exactly as the provider sent it. Nothing here parses or re-encodes // it: media3 reads SubRip and WebVTT directly, and a gateway that rewrote a subtitle would // be a second thing that can be wrong about somebody's film. type DownloadedSubtitle struct { ID string ItemID string Language string Label string Forced bool HearingImpaired bool Format string Provider string Content []byte CreatedAt time.Time } // PutDownloadedSubtitle stores a fetched subtitle, replacing any previous file under the // same id. The id is the gateway's own and is derived from what was asked for, so fetching // the same language for the same title twice replaces the file rather than growing a // second track the viewer has to tell apart. func (s *Store) PutDownloadedSubtitle(ctx context.Context, subtitle DownloadedSubtitle) error { _, err := s.pool.Exec(ctx, ` INSERT INTO downloaded_subtitles (id, item_id, language, label, forced, hearing_impaired, format, provider, content, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) ON CONFLICT (id) DO UPDATE SET item_id = EXCLUDED.item_id, language = EXCLUDED.language, label = EXCLUDED.label, forced = EXCLUDED.forced, hearing_impaired = EXCLUDED.hearing_impaired, format = EXCLUDED.format, provider = EXCLUDED.provider, content = EXCLUDED.content, created_at = now()`, subtitle.ID, subtitle.ItemID, subtitle.Language, subtitle.Label, subtitle.Forced, subtitle.HearingImpaired, subtitle.Format, subtitle.Provider, subtitle.Content) if err != nil { return fmt.Errorf("store: write downloaded subtitle: %w", err) } return nil } // DownloadedSubtitle returns one stored file, content and all. It is the read the serving // route makes, so it is a single primary-key lookup. func (s *Store) DownloadedSubtitle(ctx context.Context, id string) (DownloadedSubtitle, error) { var subtitle DownloadedSubtitle err := s.pool.QueryRow(ctx, ` SELECT id, item_id, language, label, forced, hearing_impaired, format, provider, content, created_at FROM downloaded_subtitles WHERE id = $1`, id).Scan( &subtitle.ID, &subtitle.ItemID, &subtitle.Language, &subtitle.Label, &subtitle.Forced, &subtitle.HearingImpaired, &subtitle.Format, &subtitle.Provider, &subtitle.Content, &subtitle.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return DownloadedSubtitle{}, ErrSubtitleNotFound } if err != nil { return DownloadedSubtitle{}, fmt.Errorf("store: read downloaded subtitle: %w", err) } return subtitle, nil } // DownloadedSubtitlesFor lists what the gateway holds for one item, without the file // bodies. It runs on the playback path — every launch of a title asks — so it must never // read the content column. func (s *Store) DownloadedSubtitlesFor(ctx context.Context, itemID string) ([]DownloadedSubtitle, error) { rows, err := s.pool.Query(ctx, ` SELECT id, item_id, language, label, forced, hearing_impaired, format, provider, created_at FROM downloaded_subtitles WHERE item_id = $1 ORDER BY created_at`, itemID) if err != nil { return nil, fmt.Errorf("store: list downloaded subtitles: %w", err) } defer rows.Close() var out []DownloadedSubtitle for rows.Next() { var subtitle DownloadedSubtitle if err := rows.Scan( &subtitle.ID, &subtitle.ItemID, &subtitle.Language, &subtitle.Label, &subtitle.Forced, &subtitle.HearingImpaired, &subtitle.Format, &subtitle.Provider, &subtitle.CreatedAt, ); err != nil { return nil, fmt.Errorf("store: scan downloaded subtitle: %w", err) } out = append(out, subtitle) } return out, rows.Err() } // DownloadedSubtitleStats is what the console reports: how much has been fetched, and // when the last one was. Both are one aggregate query, because the page polls. type DownloadedSubtitleStats struct { Count int `json:"count"` Bytes int64 `json:"bytes"` Latest time.Time `json:"latest,omitempty"` } func (s *Store) DownloadedSubtitleStats(ctx context.Context) (DownloadedSubtitleStats, error) { var stats DownloadedSubtitleStats var latest *time.Time err := s.pool.QueryRow(ctx, ` SELECT count(*), coalesce(sum(length(content)), 0), max(created_at) FROM downloaded_subtitles`).Scan(&stats.Count, &stats.Bytes, &latest) if err != nil { return DownloadedSubtitleStats{}, fmt.Errorf("store: downloaded subtitle stats: %w", err) } if latest != nil { stats.Latest = *latest } return stats, nil } // ClearDownloadedSubtitles empties the store. It is the console's one destructive control // here, and it is safe in the way a cache purge is: every file can be fetched again, at // the cost of the provider allowance that fetched it. func (s *Store) ClearDownloadedSubtitles(ctx context.Context) (int64, error) { tag, err := s.pool.Exec(ctx, `DELETE FROM downloaded_subtitles`) if err != nil { return 0, fmt.Errorf("store: clear downloaded subtitles: %w", err) } return tag.RowsAffected(), nil }