236 lines
9.5 KiB
Go
236 lines
9.5 KiB
Go
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
|
||
|
|
}
|