Release v0.2.34
This commit is contained in:
@@ -403,3 +403,53 @@ CREATE TABLE IF NOT EXISTS user_preference_acks (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_preference_acks_user_revision_idx
|
||||
ON user_preference_acks (emby_user_id, revision DESC);
|
||||
|
||||
-- Which colour schemes an operator has decided a particular viewer may choose from.
|
||||
--
|
||||
-- Deliberately not part of user_preferences: that document is the viewer's own choices and
|
||||
-- is written by every television they own, where this is policy about them and is written
|
||||
-- only by the console. Keeping them apart is what stops a TV pushing itself a theme it was
|
||||
-- not offered simply by including the id in a settings write.
|
||||
--
|
||||
-- **A person with no rows here may choose anything.** Absence is permissive, because no row
|
||||
-- exists for anybody until an operator restricts somebody — reading it the other way round
|
||||
-- would empty every picker in the house the day this ships. It also means "allowed
|
||||
-- everything" and "never configured" are stored identically, which is correct: they are the
|
||||
-- same decision.
|
||||
--
|
||||
-- Seasonal themes are never in here. They are not grantable per person; the only switch is
|
||||
-- the seasonal_themes feature flag, and it is the operator's, for the whole household.
|
||||
CREATE TABLE IF NOT EXISTS user_themes (
|
||||
emby_user_id TEXT NOT NULL,
|
||||
theme_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (emby_user_id, theme_id)
|
||||
);
|
||||
|
||||
-- Subtitles the gateway fetched itself, which is the one place Memby holds a subtitle.
|
||||
--
|
||||
-- Bazarr does not need this: it writes the file beside the media file, so Emby finds it
|
||||
-- and the track arrives down the ordinary PlaybackInfo path. OpenSubtitles has no such
|
||||
-- reach — the gateway has no access to the media directory — so a file fetched from it is
|
||||
-- kept here and served back as a sidecar. That is the whole difference between the two
|
||||
-- providers, and it is why this table exists at all.
|
||||
--
|
||||
-- It is deliberately durable rather than a cache. A subtitle somebody fetched mid-film is
|
||||
-- one they will want again on the next episode of the same evening and on a rewatch a year
|
||||
-- later; spending a provider's daily download quota twice for the same file would be the
|
||||
-- feature working against the household. Rows are small — a subtitle is tens of kilobytes
|
||||
-- — and are deleted with nothing else, because nothing else knows the file exists.
|
||||
CREATE TABLE IF NOT EXISTS downloaded_subtitles (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
forced BOOLEAN NOT NULL DEFAULT false,
|
||||
hearing_impaired BOOLEAN NOT NULL DEFAULT false,
|
||||
format TEXT NOT NULL DEFAULT 'srt',
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
content BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Search history is the record of what a household looks for, and it has two readers with
|
||||
// quite different appetites: a television asking for one viewer's last few queries, and
|
||||
// the console asking what the house as a whole has been searching. Both read the one
|
||||
// table, which is why the writer's rules live here beside them.
|
||||
|
||||
// SearchDedupeWindow is how long an identical query counts as the same search.
|
||||
//
|
||||
// Two things write this table for one search — the gateway's own /v1/search handler and
|
||||
// the client's POST to /v1/search/history — and a television that repeats a query while
|
||||
// somebody re-reads the results is not a second search either. The window is short enough
|
||||
// that a query typed again a minute later is its own row, which is what makes the table a
|
||||
// record of what a household looks for rather than of how its remote behaves.
|
||||
const SearchDedupeWindow = 30 * time.Second
|
||||
|
||||
// SearchRetention is how far back the table goes. RecordSearch prunes to it on every
|
||||
// write, so it is also the honest ceiling on any window the console offers: a page
|
||||
// promising 90 days would draw a flat line for two thirds of it.
|
||||
const SearchRetention = 30 * 24 * time.Hour
|
||||
|
||||
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
||||
//
|
||||
// Case-insensitive within the dedupe window, matching RecentSearches, which collapses
|
||||
// case-only duplicates when it reads them back.
|
||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`WITH inserted AS (
|
||||
INSERT INTO search_history (emby_user_id, query)
|
||||
SELECT $1, $2
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND lower(query) = lower($2)
|
||||
AND occurred_at > now() - $3::interval
|
||||
)
|
||||
RETURNING id
|
||||
)
|
||||
DELETE FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND occurred_at < now() - $4::interval`,
|
||||
userID, query, SearchDedupeWindow.String(), SearchRetention.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// RecentSearches returns a user's distinct queries in most-recently-used order.
|
||||
// Case-only duplicates collapse to the spelling used most recently.
|
||||
func (s *Store) RecentSearches(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query
|
||||
FROM (
|
||||
SELECT DISTINCT ON (lower(query)) query, occurred_at
|
||||
FROM search_history
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
||||
ORDER BY lower(query), occurred_at DESC
|
||||
) AS latest
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $3`,
|
||||
userID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent searches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
queries := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var query string
|
||||
if err := rows.Scan(&query); err != nil {
|
||||
return nil, fmt.Errorf("store: scan recent search: %w", err)
|
||||
}
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read recent searches: %w", err)
|
||||
}
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
// SearchTerm is one query the household searched for, aggregated across everyone.
|
||||
type SearchTerm struct {
|
||||
Query string `json:"query"`
|
||||
Searches int `json:"searches"`
|
||||
Viewers int `json:"viewers"`
|
||||
LastAt time.Time `json:"lastAt"`
|
||||
}
|
||||
|
||||
// SearchEvent is one search as it happened: the log rather than the summary.
|
||||
type SearchEvent struct {
|
||||
Query string `json:"query"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
// SearchTotals describes a window as a whole. Counted separately from SearchTerms because
|
||||
// that list is capped — summing a top-twenty would report the top twenty's total as the
|
||||
// household's, which is wrong by however long the tail is.
|
||||
type SearchTotals struct {
|
||||
Searches int `json:"searches"`
|
||||
Queries int `json:"queries"`
|
||||
Viewers int `json:"viewers"`
|
||||
}
|
||||
|
||||
// SearchTerms aggregates the household's queries since a point in time, most-searched
|
||||
// first. Grouped case-insensitively and labelled with the spelling used most recently,
|
||||
// the same rule RecentSearches applies, so one query cannot appear as two rows because
|
||||
// somebody's on-screen keyboard capitalised it.
|
||||
func (s *Store) SearchTerms(ctx context.Context, since time.Time, limit int) ([]SearchTerm, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT (array_agg(query ORDER BY occurred_at DESC))[1] AS query,
|
||||
count(*) AS searches,
|
||||
count(DISTINCT emby_user_id) AS viewers,
|
||||
max(occurred_at) AS last_at
|
||||
FROM search_history
|
||||
WHERE occurred_at >= $1
|
||||
GROUP BY lower(query)
|
||||
ORDER BY searches DESC, last_at DESC
|
||||
LIMIT $2`, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: search terms: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
terms := []SearchTerm{}
|
||||
for rows.Next() {
|
||||
var term SearchTerm
|
||||
if err := rows.Scan(&term.Query, &term.Searches, &term.Viewers, &term.LastAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan search term: %w", err)
|
||||
}
|
||||
terms = append(terms, term)
|
||||
}
|
||||
return terms, rows.Err()
|
||||
}
|
||||
|
||||
// SearchEvents returns the raw log, newest first.
|
||||
//
|
||||
// Deliberately not collapsed: the summary above answers "what does this house look for",
|
||||
// and this answers "what happened just now" — which is the one an operator needs when
|
||||
// somebody says search is not finding something, because it shows the query exactly as it
|
||||
// was typed, by whom, and at what time. Usernames are resolved by the caller from
|
||||
// KnownUsers: they live in sessions and joining a log to them per row would make the
|
||||
// query's cost depend on how many televisions the household has ever signed in.
|
||||
func (s *Store) SearchEvents(ctx context.Context, since time.Time, limit int) ([]SearchEvent, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query, emby_user_id, occurred_at
|
||||
FROM search_history
|
||||
WHERE occurred_at >= $1
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $2`, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: search events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := []SearchEvent{}
|
||||
for rows.Next() {
|
||||
var event SearchEvent
|
||||
if err := rows.Scan(&event.Query, &event.UserID, &event.OccurredAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan search event: %w", err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// SearchTotals counts a window: searches made, distinct queries behind them, and how many
|
||||
// of the household did the searching.
|
||||
func (s *Store) SearchTotals(ctx context.Context, since time.Time) (SearchTotals, error) {
|
||||
var totals SearchTotals
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT count(*), count(DISTINCT lower(query)), count(DISTINCT emby_user_id)
|
||||
FROM search_history
|
||||
WHERE occurred_at >= $1`, since).
|
||||
Scan(&totals.Searches, &totals.Queries, &totals.Viewers)
|
||||
if err != nil {
|
||||
return SearchTotals{}, fmt.Errorf("store: search totals: %w", err)
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
@@ -310,58 +310,6 @@ func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`WITH inserted AS (
|
||||
INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2)
|
||||
RETURNING id
|
||||
)
|
||||
DELETE FROM search_history
|
||||
WHERE emby_user_id = $1
|
||||
AND occurred_at < now() - interval '30 days'`,
|
||||
userID, query)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecentSearches returns a user's distinct queries in most-recently-used order.
|
||||
// Case-only duplicates collapse to the spelling used most recently.
|
||||
func (s *Store) RecentSearches(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
limit int,
|
||||
) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT query
|
||||
FROM (
|
||||
SELECT DISTINCT ON (lower(query)) query, occurred_at
|
||||
FROM search_history
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
||||
ORDER BY lower(query), occurred_at DESC
|
||||
) AS latest
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $3`,
|
||||
userID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recent searches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
queries := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var query string
|
||||
if err := rows.Scan(&query); err != nil {
|
||||
return nil, fmt.Errorf("store: scan recent search: %w", err)
|
||||
}
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: read recent searches: %w", err)
|
||||
}
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
// Migrate applies the schema. It is idempotent, so it runs on every boot.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
if _, err := s.pool.Exec(ctx, schema); err != nil {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Which colour schemes an operator has decided a viewer may choose from.
|
||||
//
|
||||
// The store deliberately knows nothing about what a theme *is*: the catalogue, the palettes
|
||||
// and the rule about seasons all live in internal/api next to the client contract, the same
|
||||
// division user_preferences draws. What is here is only the set of ids, and the one property
|
||||
// that has to be true at this level — that an empty result means "unrestricted", never "no
|
||||
// themes at all". See the table comment in schema.sql.
|
||||
|
||||
// UserThemes is the ids this viewer may pick between, or an empty slice for anybody the
|
||||
// operator has never restricted — which is everybody, until they do.
|
||||
func (s *Store) UserThemes(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT theme_id FROM user_themes WHERE emby_user_id = $1 ORDER BY theme_id`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read user themes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
themes := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user theme: %w", err)
|
||||
}
|
||||
themes = append(themes, id)
|
||||
}
|
||||
return themes, rows.Err()
|
||||
}
|
||||
|
||||
// AllUserThemes is the admin console's read: one query for the whole accounts page rather
|
||||
// than one per person, since that page already fans out over every account it lists.
|
||||
func (s *Store) AllUserThemes(ctx context.Context) (map[string][]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT emby_user_id, theme_id FROM user_themes ORDER BY emby_user_id, theme_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list user themes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
all := map[string][]string{}
|
||||
for rows.Next() {
|
||||
var userID, themeID string
|
||||
if err := rows.Scan(&userID, &themeID); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user themes: %w", err)
|
||||
}
|
||||
all[userID] = append(all[userID], themeID)
|
||||
}
|
||||
return all, rows.Err()
|
||||
}
|
||||
|
||||
// SetUserThemes replaces this viewer's allowlist wholesale.
|
||||
//
|
||||
// Replace rather than merge because the console sends the whole set of ticked boxes, and a
|
||||
// merge would make unticking one impossible. It is one transaction so a viewer is never
|
||||
// momentarily allowed nothing — a television resolving its theme in that window would be
|
||||
// told the default and would repaint itself for no reason.
|
||||
//
|
||||
// An empty list deletes the rows rather than storing anything, which is what keeps
|
||||
// "unrestricted" a single representation. api.normalizeThemeAllowlist is what turns "every
|
||||
// box ticked" into that empty list before it arrives here.
|
||||
func (s *Store) SetUserThemes(ctx context.Context, userID string, themes []string) error {
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM user_themes WHERE emby_user_id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("store: clear user themes: %w", err)
|
||||
}
|
||||
for _, id := range themes {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_themes (emby_user_id, theme_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, userID, id); err != nil {
|
||||
return fmt.Errorf("store: write user theme: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user