0.2.82
This commit is contained in:
@@ -108,6 +108,12 @@ CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (
|
||||
CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at);
|
||||
|
||||
-- Every episode of one series, which is what a viewer's Next Up walks and what a series
|
||||
-- card's watched count is computed from. Both run on the tail of an ordinary request, and
|
||||
-- without this each is a scan of every episode in the library.
|
||||
CREATE INDEX IF NOT EXISTS library_items_series_episodes_idx
|
||||
ON library_items (series_id) WHERE type = 'Episode';
|
||||
|
||||
-- Durable raw MDBList responses. Source selection and display formatting happen at read
|
||||
-- time, so changing the visible sources does not require another external API request.
|
||||
CREATE TABLE IF NOT EXISTS external_media_ratings (
|
||||
@@ -903,3 +909,76 @@ CREATE INDEX IF NOT EXISTS notification_log_user_idx
|
||||
ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> '';
|
||||
CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);
|
||||
|
||||
-- Viewers: the people using one Memby account.
|
||||
--
|
||||
-- A Memby account is the household's relationship with an Emby user; a viewer is one
|
||||
-- person under it. Every account has exactly one MAIN viewer, whose state is Emby's and
|
||||
-- which behaves exactly as the account did before viewers existed, and any number of
|
||||
-- SHADOW viewers whose state is Memby's alone.
|
||||
--
|
||||
-- The main viewer's id IS the Emby user id, and that is the whole of why this feature
|
||||
-- needed no migration. Every table in this schema keys a person by a bare emby_user_id
|
||||
-- with no foreign key behind it, so substituting a viewer id for it leaves an existing
|
||||
-- household's preferences, notifications, followed shows, search history and row stats
|
||||
-- exactly where they were. A shadow id is prefixed 'v' and is therefore distinguishable
|
||||
-- from Emby's 32-hex GUIDs by inspection, which is what makes that substitution safe.
|
||||
CREATE TABLE IF NOT EXISTS viewers (
|
||||
id TEXT PRIMARY KEY,
|
||||
emby_user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
short_name TEXT NOT NULL DEFAULT '',
|
||||
colour TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL, -- main | shadow
|
||||
pin_hash BYTEA,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS viewers_account_idx ON viewers (emby_user_id, created_at);
|
||||
|
||||
-- One main viewer per account, enforced rather than assumed: the main viewer is what a
|
||||
-- request falls back to, so an account with two of them would resolve differently
|
||||
-- depending on which row a query happened to return first.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS viewers_account_main_idx
|
||||
ON viewers (emby_user_id) WHERE kind = 'main';
|
||||
|
||||
-- A shadow viewer's own viewing state, in the shape of the Emby UserData block it stands
|
||||
-- in for. Only the fields Memby actually renders are here: the Emby item id is the common
|
||||
-- identifier, so no library metadata is duplicated and nothing here needs invalidating
|
||||
-- when the catalogue changes.
|
||||
--
|
||||
-- There is deliberately no row for a main viewer. Their state lives in Emby, and a copy
|
||||
-- of it here would be a second answer free to disagree with the one the household's other
|
||||
-- Emby clients see.
|
||||
CREATE TABLE IF NOT EXISTS viewer_playback_state (
|
||||
viewer_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
series_id TEXT NOT NULL DEFAULT '',
|
||||
season_id TEXT NOT NULL DEFAULT '',
|
||||
position_ticks BIGINT NOT NULL DEFAULT 0,
|
||||
runtime_ticks BIGINT NOT NULL DEFAULT 0,
|
||||
played BOOLEAN NOT NULL DEFAULT false,
|
||||
play_count INT NOT NULL DEFAULT 0,
|
||||
favourite BOOLEAN NOT NULL DEFAULT false,
|
||||
hidden_from_resume BOOLEAN NOT NULL DEFAULT false,
|
||||
last_played_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (viewer_id, item_id)
|
||||
);
|
||||
|
||||
-- Continue Watching for a shadow viewer is this index: what they are part-way through,
|
||||
-- most recent first. The partial predicate keeps it to the rows that row can draw from
|
||||
-- rather than to everything they have ever pressed Play on.
|
||||
CREATE INDEX IF NOT EXISTS viewer_playback_resume_idx
|
||||
ON viewer_playback_state (viewer_id, last_played_at DESC)
|
||||
WHERE position_ticks > 0 AND NOT played AND NOT hidden_from_resume;
|
||||
|
||||
-- Next Up walks a series' episodes for the newest completion; favourites are their own
|
||||
-- row, and both are asked for per viewer.
|
||||
CREATE INDEX IF NOT EXISTS viewer_playback_series_idx
|
||||
ON viewer_playback_state (viewer_id, series_id, last_played_at DESC)
|
||||
WHERE series_id <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS viewer_playback_favourite_idx
|
||||
ON viewer_playback_state (viewer_id, updated_at DESC) WHERE favourite;
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ViewerState is one viewer's answer about one title, in the shape of the Emby UserData
|
||||
// block it stands in for. Zero values are the honest answer for a title nobody has
|
||||
// touched, which is what lets a caller decorate an item it found nothing stored for
|
||||
// without a special case.
|
||||
type ViewerState struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
SeasonID string
|
||||
PositionTicks int64
|
||||
RuntimeTicks int64
|
||||
Played bool
|
||||
PlayCount int
|
||||
Favourite bool
|
||||
HiddenFromResume bool
|
||||
LastPlayedAt *time.Time
|
||||
}
|
||||
|
||||
// PlayedFraction is the share of a title that must be behind the viewer for it to count as
|
||||
// watched. It matches Emby's own default so a household cannot come to disagree with itself
|
||||
// about whether an episode is finished depending on which viewer watched it.
|
||||
const PlayedFraction = 0.9
|
||||
|
||||
// PlayedFromPosition decides whether a stop report completed the title.
|
||||
//
|
||||
// A runtime of zero means the length was not known rather than that the title is zero
|
||||
// long, so it can never complete anything — the alternative is that every report with a
|
||||
// missing duration marks something watched at the first second.
|
||||
func PlayedFromPosition(positionTicks, runtimeTicks int64) bool {
|
||||
if runtimeTicks <= 0 || positionTicks <= 0 {
|
||||
return false
|
||||
}
|
||||
return float64(positionTicks) >= float64(runtimeTicks)*PlayedFraction
|
||||
}
|
||||
|
||||
// RecordViewerPlayback writes a progress or stop report for a shadow viewer.
|
||||
//
|
||||
// A completed title is stored at position zero, the way Emby stores one: the position is
|
||||
// what Continue Watching reads, and a finished episode left sitting at its last frame is
|
||||
// one the row keeps offering to resume four seconds from the end. play_count only moves on
|
||||
// the transition into played, so the ten-second reports either side of the threshold cannot
|
||||
// count one viewing several times.
|
||||
func (s *Store) RecordViewerPlayback(ctx context.Context, viewerID string, state ViewerState) error {
|
||||
if viewerID == "" || state.ItemID == "" {
|
||||
return fmt.Errorf("store: viewer playback: viewer and item are required")
|
||||
}
|
||||
position := state.PositionTicks
|
||||
if position < 0 {
|
||||
position = 0
|
||||
}
|
||||
runtime := state.RuntimeTicks
|
||||
if runtime < 0 {
|
||||
runtime = 0
|
||||
}
|
||||
if state.Played {
|
||||
position = 0
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO viewer_playback_state (
|
||||
viewer_id, item_id, series_id, season_id,
|
||||
position_ticks, runtime_ticks, played, play_count, last_played_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2,
|
||||
-- The series is read out of the shared catalogue rather than asked of Emby or
|
||||
-- carried by the television: it is already there, it is what orders this
|
||||
-- viewer's Continue Watching, and a report arrives every ten seconds.
|
||||
COALESCE(NULLIF($3, ''), (SELECT series_id FROM library_items WHERE id = $2), ''),
|
||||
$4, $5, $6, $7, CASE WHEN $7 THEN 1 ELSE 0 END, now(), now())
|
||||
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
|
||||
series_id = CASE WHEN excluded.series_id <> '' THEN excluded.series_id
|
||||
ELSE viewer_playback_state.series_id END,
|
||||
season_id = CASE WHEN excluded.season_id <> '' THEN excluded.season_id
|
||||
ELSE viewer_playback_state.season_id END,
|
||||
position_ticks = excluded.position_ticks,
|
||||
runtime_ticks = CASE WHEN excluded.runtime_ticks > 0 THEN excluded.runtime_ticks
|
||||
ELSE viewer_playback_state.runtime_ticks END,
|
||||
played = excluded.played,
|
||||
play_count = viewer_playback_state.play_count
|
||||
+ CASE WHEN excluded.played AND NOT viewer_playback_state.played
|
||||
THEN 1 ELSE 0 END,
|
||||
last_played_at = now(),
|
||||
updated_at = now()`,
|
||||
viewerID, state.ItemID, state.SeriesID, state.SeasonID,
|
||||
position, runtime, state.Played,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: record viewer playback: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetViewerPlayed marks a title watched or unwatched by hand.
|
||||
//
|
||||
// Marking unwatched clears the position for the same reason marking watched does: the two
|
||||
// are one statement about where this viewer stands with the title, and a cleared flag over
|
||||
// a retained playhead would put it straight back into Continue Watching at the closing
|
||||
// credits.
|
||||
func (s *Store) SetViewerPlayed(ctx context.Context, viewerID, itemID string, played bool) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO viewer_playback_state (
|
||||
viewer_id, item_id, position_ticks, played, play_count, last_played_at, updated_at
|
||||
) VALUES ($1, $2, 0, $3, CASE WHEN $3 THEN 1 ELSE 0 END,
|
||||
CASE WHEN $3 THEN now() ELSE NULL END, now())
|
||||
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
|
||||
position_ticks = 0,
|
||||
played = excluded.played,
|
||||
play_count = viewer_playback_state.play_count
|
||||
+ CASE WHEN excluded.played AND NOT viewer_playback_state.played
|
||||
THEN 1 ELSE 0 END,
|
||||
last_played_at = CASE WHEN excluded.played
|
||||
THEN COALESCE(viewer_playback_state.last_played_at, now())
|
||||
ELSE viewer_playback_state.last_played_at END,
|
||||
updated_at = now()`,
|
||||
viewerID, itemID, played)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set viewer played: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetViewerFavourite records a favourite that belongs to the person rather than to the
|
||||
// Emby account, so one viewer's heart cannot appear on everybody else's launcher.
|
||||
func (s *Store) SetViewerFavourite(ctx context.Context, viewerID, itemID string, favourite bool) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO viewer_playback_state (viewer_id, item_id, favourite, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
|
||||
favourite = excluded.favourite, updated_at = now()`,
|
||||
viewerID, itemID, favourite)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set viewer favourite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HideViewerFromResume takes a title off this viewer's Continue Watching without claiming
|
||||
// they watched it. The position is kept: hiding is a statement about the row, not about
|
||||
// where they got to, and pressing Play again should still resume.
|
||||
func (s *Store) HideViewerFromResume(ctx context.Context, viewerID, itemID string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO viewer_playback_state (viewer_id, item_id, hidden_from_resume, updated_at)
|
||||
VALUES ($1, $2, true, now())
|
||||
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
|
||||
hidden_from_resume = true, updated_at = now()`,
|
||||
viewerID, itemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: hide from resume: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ViewerStateFor reads one title's state. A title with no row is not an error: it is a
|
||||
// title this viewer has never touched, which is the ordinary case.
|
||||
func (s *Store) ViewerStateFor(ctx context.Context, viewerID, itemID string) (ViewerState, error) {
|
||||
state := ViewerState{ItemID: itemID}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT series_id, season_id, position_ticks, runtime_ticks,
|
||||
played, play_count, favourite, hidden_from_resume, last_played_at
|
||||
FROM viewer_playback_state WHERE viewer_id = $1 AND item_id = $2`,
|
||||
viewerID, itemID,
|
||||
).Scan(
|
||||
&state.SeriesID, &state.SeasonID, &state.PositionTicks, &state.RuntimeTicks,
|
||||
&state.Played, &state.PlayCount, &state.Favourite, &state.HiddenFromResume,
|
||||
&state.LastPlayedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return state, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ViewerState{}, fmt.Errorf("store: viewer state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// ViewerStates reads a whole launcher's worth in one query.
|
||||
//
|
||||
// This is the read behind every decorated row, so it is one indexed lookup for several
|
||||
// hundred cards rather than a request per card — the economy decorateItemRatings already
|
||||
// makes for scores.
|
||||
func (s *Store) ViewerStates(
|
||||
ctx context.Context, viewerID string, itemIDs []string,
|
||||
) (map[string]ViewerState, error) {
|
||||
states := map[string]ViewerState{}
|
||||
if viewerID == "" || len(itemIDs) == 0 {
|
||||
return states, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, series_id, season_id, position_ticks, runtime_ticks,
|
||||
played, play_count, favourite, hidden_from_resume, last_played_at
|
||||
FROM viewer_playback_state
|
||||
WHERE viewer_id = $1 AND item_id = ANY($2)`, viewerID, itemIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer states: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var state ViewerState
|
||||
if err := rows.Scan(
|
||||
&state.ItemID, &state.SeriesID, &state.SeasonID,
|
||||
&state.PositionTicks, &state.RuntimeTicks,
|
||||
&state.Played, &state.PlayCount, &state.Favourite, &state.HiddenFromResume,
|
||||
&state.LastPlayedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan viewer state: %w", err)
|
||||
}
|
||||
states[state.ItemID] = state
|
||||
}
|
||||
return states, rows.Err()
|
||||
}
|
||||
|
||||
// ViewerResumeItems is this viewer's Continue Watching, most recently played first.
|
||||
//
|
||||
// It answers in item ids alone: the catalogue is shared by the household and is read from
|
||||
// library_items or Emby, so duplicating a single field of metadata here would be a second
|
||||
// copy free to go stale.
|
||||
func (s *Store) ViewerResumeItems(ctx context.Context, viewerID string, limit int) ([]string, error) {
|
||||
if viewerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id FROM viewer_playback_state
|
||||
WHERE viewer_id = $1 AND position_ticks > 0 AND NOT played AND NOT hidden_from_resume
|
||||
ORDER BY last_played_at DESC NULLS LAST
|
||||
LIMIT $2`, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer resume items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: scan resume item: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ViewerFavouriteItems is this viewer's favourites, most recently marked first.
|
||||
func (s *Store) ViewerFavouriteItems(ctx context.Context, viewerID string, limit int) ([]string, error) {
|
||||
if viewerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id FROM viewer_playback_state
|
||||
WHERE viewer_id = $1 AND favourite
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2`, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer favourites: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: scan favourite: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ViewerWatchedSeries reports, per series, when this viewer last finished or watched an
|
||||
// episode of it. It is what orders a shadow viewer's Continue Watching, which merges
|
||||
// resumable items with the next unwatched episode of a series they are part-way through —
|
||||
// and a Next Up episode has no time of its own, so it is placed by its series.
|
||||
func (s *Store) ViewerWatchedSeries(
|
||||
ctx context.Context, viewerID string, limit int,
|
||||
) (map[string]time.Time, error) {
|
||||
watched := map[string]time.Time{}
|
||||
if viewerID == "" {
|
||||
return watched, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 40
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT series_id, max(last_played_at) AS played_at
|
||||
FROM viewer_playback_state
|
||||
WHERE viewer_id = $1 AND series_id <> '' AND last_played_at IS NOT NULL
|
||||
GROUP BY series_id
|
||||
ORDER BY played_at DESC
|
||||
LIMIT $2`, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer watched series: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var seriesID string
|
||||
var playedAt time.Time
|
||||
if err := rows.Scan(&seriesID, &playedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan watched series: %w", err)
|
||||
}
|
||||
watched[seriesID] = playedAt
|
||||
}
|
||||
return watched, rows.Err()
|
||||
}
|
||||
|
||||
// ViewerPlayedInSeries reports which of a series' episodes this viewer has finished, which
|
||||
// is what Next Up walks to find the first one they have not.
|
||||
func (s *Store) ViewerPlayedInSeries(
|
||||
ctx context.Context, viewerID, seriesID string,
|
||||
) (map[string]bool, error) {
|
||||
played := map[string]bool{}
|
||||
if viewerID == "" || seriesID == "" {
|
||||
return played, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, played FROM viewer_playback_state
|
||||
WHERE viewer_id = $1 AND series_id = $2`, viewerID, seriesID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer played in series: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var itemID string
|
||||
var done bool
|
||||
if err := rows.Scan(&itemID, &done); err != nil {
|
||||
return nil, fmt.Errorf("store: scan played episode: %w", err)
|
||||
}
|
||||
played[itemID] = done
|
||||
}
|
||||
return played, rows.Err()
|
||||
}
|
||||
|
||||
// ViewerNextUp is the next unwatched episode of every series this viewer is part-way
|
||||
// through, the series they watched most recently first.
|
||||
//
|
||||
// It is computed entirely in Postgres, out of the shared catalogue and this viewer's own
|
||||
// state, because Emby's own NextUp answers for the *account* and there is nobody else to
|
||||
// ask. That also makes it cheap: the alternative — walking each series' episode list over
|
||||
// the wire — is one Emby request per show on the tail of the launcher.
|
||||
//
|
||||
// Three rules, each of which Emby's own answer also applies:
|
||||
//
|
||||
// An episode already resumable is left out, because it is in Continue Watching already and
|
||||
// the merge would otherwise offer the same show twice.
|
||||
//
|
||||
// Specials are not next episodes. Season 0 is a real season and a perfectly good thing to
|
||||
// watch, but it is not what "next" means, and a show whose specials sort first would never
|
||||
// offer anything else.
|
||||
//
|
||||
// A series with nothing unwatched left simply contributes no row rather than an empty one.
|
||||
func (s *Store) ViewerNextUp(ctx context.Context, viewerID string, limit int) ([]string, error) {
|
||||
if viewerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH watched AS (
|
||||
SELECT series_id, max(last_played_at) AS played_at
|
||||
FROM viewer_playback_state
|
||||
WHERE viewer_id = $1 AND series_id <> '' AND last_played_at IS NOT NULL
|
||||
GROUP BY series_id
|
||||
),
|
||||
episodes AS (
|
||||
SELECT li.id,
|
||||
li.series_id,
|
||||
COALESCE((li.payload->>'ParentIndexNumber')::int, 0) AS season,
|
||||
COALESCE((li.payload->>'IndexNumber')::int, 0) AS episode,
|
||||
w.played_at
|
||||
FROM library_items li
|
||||
JOIN watched w ON w.series_id = li.series_id
|
||||
WHERE li.type = 'Episode'
|
||||
AND COALESCE((li.payload->>'ParentIndexNumber')::int, 0) > 0
|
||||
),
|
||||
unplayed AS (
|
||||
SELECT e.id, e.played_at,
|
||||
row_number() OVER (
|
||||
PARTITION BY e.series_id ORDER BY e.season, e.episode, e.id
|
||||
) AS rank
|
||||
FROM episodes e
|
||||
LEFT JOIN viewer_playback_state vps
|
||||
ON vps.viewer_id = $1 AND vps.item_id = e.id
|
||||
WHERE COALESCE(vps.played, false) = false
|
||||
AND COALESCE(vps.position_ticks, 0) = 0
|
||||
)
|
||||
SELECT id FROM unplayed WHERE rank = 1
|
||||
ORDER BY played_at DESC
|
||||
LIMIT $2`, viewerID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer next up: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: scan next up: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ViewerAggregate is what a series or a season card says about a viewer: how much of it
|
||||
// there is, how much of it is behind them, and when they last watched any of it.
|
||||
//
|
||||
// It stands in for the block Emby fills in from an item's children, which is the one place
|
||||
// a shadow viewer was still shown the *account's* answer — a series card ticked because
|
||||
// somebody else had finished it. Emby computes it per Emby user and there is nobody to ask
|
||||
// for a person Emby has never heard of, so it is computed here out of the shared catalogue
|
||||
// and this viewer's own state.
|
||||
type ViewerAggregate struct {
|
||||
// Total is how many episodes the catalogue holds. Zero means the catalogue cannot
|
||||
// answer — a library not yet imported, or a series it has never seen — which is a
|
||||
// different thing from a series with nothing in it, and the caller must not print a
|
||||
// count for it.
|
||||
Total int
|
||||
// Played is how many of those this viewer has finished.
|
||||
Played int
|
||||
// LastPlayedAt is the most recent episode of it they touched, finished or not, which
|
||||
// is what orders a shelf.
|
||||
LastPlayedAt *time.Time
|
||||
}
|
||||
|
||||
// ViewerContainerStates aggregates a viewer's episode state per series *and* per season.
|
||||
//
|
||||
// One map keyed by container id serves both, because a series id and a season id are both
|
||||
// Emby GUIDs and cannot collide — so the caller looks an item up by its own id and does not
|
||||
// have to know which of the two it is holding.
|
||||
//
|
||||
// The query groups by the **pair** and the two rollups are done here rather than in SQL.
|
||||
// That is a deliberately dull query — no grouping sets, no second pass over the same rows —
|
||||
// and it is exact for both answers because a season belongs to exactly one series, so
|
||||
// summing a series' seasons is summing its episodes. It runs on the tail of every request
|
||||
// that serves a series card, which is why the index it reads
|
||||
// (library_items_series_episodes_idx) exists.
|
||||
//
|
||||
// One thing to know about it: a series is only counted completely if it was *asked* for.
|
||||
// A season whose series was not in seriesIDs contributes to a partial series total, which
|
||||
// is harmless only because nothing looks that series up — containerIDsIn asks for a
|
||||
// season's series alongside it precisely so the case cannot arise for anything drawn.
|
||||
func (s *Store) ViewerContainerStates(
|
||||
ctx context.Context, viewerID string, seriesIDs, seasonIDs []string,
|
||||
) (map[string]ViewerAggregate, error) {
|
||||
aggregates := map[string]ViewerAggregate{}
|
||||
if viewerID == "" || (len(seriesIDs) == 0 && len(seasonIDs) == 0) {
|
||||
return aggregates, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT li.series_id,
|
||||
COALESCE(li.payload->>'SeasonId', '') AS season_id,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE COALESCE(vps.played, false)) AS played,
|
||||
max(vps.last_played_at) AS last_played_at
|
||||
FROM library_items li
|
||||
LEFT JOIN viewer_playback_state vps
|
||||
ON vps.viewer_id = $1 AND vps.item_id = li.id
|
||||
WHERE li.type = 'Episode'
|
||||
AND (li.series_id = ANY($2) OR COALESCE(li.payload->>'SeasonId', '') = ANY($3))
|
||||
GROUP BY li.series_id, COALESCE(li.payload->>'SeasonId', '')`,
|
||||
viewerID, seriesIDs, seasonIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: viewer container states: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var seriesID, seasonID string
|
||||
var total, played int
|
||||
var lastPlayedAt *time.Time
|
||||
if err := rows.Scan(&seriesID, &seasonID, &total, &played, &lastPlayedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan container state: %w", err)
|
||||
}
|
||||
// An episode filed under no series or no season contributes to neither rather than
|
||||
// to a row keyed on the empty string, which would be an aggregate about nothing.
|
||||
addViewerAggregate(aggregates, seriesID, total, played, lastPlayedAt)
|
||||
addViewerAggregate(aggregates, seasonID, total, played, lastPlayedAt)
|
||||
}
|
||||
return aggregates, rows.Err()
|
||||
}
|
||||
|
||||
// addViewerAggregate folds one season's worth of counting into a container's total.
|
||||
func addViewerAggregate(
|
||||
into map[string]ViewerAggregate, key string, total, played int, lastPlayedAt *time.Time,
|
||||
) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
aggregate := into[key]
|
||||
aggregate.Total += total
|
||||
aggregate.Played += played
|
||||
if lastPlayedAt != nil &&
|
||||
(aggregate.LastPlayedAt == nil || lastPlayedAt.After(*aggregate.LastPlayedAt)) {
|
||||
aggregate.LastPlayedAt = lastPlayedAt
|
||||
}
|
||||
into[key] = aggregate
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ViewerKind separates the one viewer whose state is Emby's from the ones whose state is
|
||||
// Memby's. It is stated on the row rather than derived from whether an id looks like an
|
||||
// Emby GUID: the id shape is a safety property, not a source of truth, and a household
|
||||
// that arrived at an odd id must not silently change which viewer publishes.
|
||||
const (
|
||||
ViewerMain = "main"
|
||||
ViewerShadow = "shadow"
|
||||
)
|
||||
|
||||
// ErrViewerNotFound is returned when an id names no viewer of the account that asked.
|
||||
var ErrViewerNotFound = errors.New("store: viewer not found")
|
||||
|
||||
// MaxShadowViewers bounds an account's list. A picker is a row of cards on a television
|
||||
// and the D-pad has to reach the end of it; this is a limit on the UI, not on the schema.
|
||||
const MaxShadowViewers = 7
|
||||
|
||||
type Viewer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"shortName,omitempty"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
HasPIN bool `json:"hasPin"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// IsMain reports whether this viewer's state is published to Emby.
|
||||
func (v Viewer) IsMain() bool { return v.Kind == ViewerMain }
|
||||
|
||||
// NewShadowViewerID mints an id that cannot be mistaken for an Emby user id.
|
||||
//
|
||||
// Emby's are 32 hex characters. This is a "v" followed by 32 more, so the two are
|
||||
// distinguishable by inspection anywhere one is read out of a log line or a cache key —
|
||||
// which matters because a viewer id is substituted for an emby_user_id in twenty tables
|
||||
// that cannot tell the difference themselves.
|
||||
func NewShadowViewerID() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("store: viewer id: %w", err)
|
||||
}
|
||||
return "v" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// IsShadowViewerID reports whether an id belongs to the shadow namespace. Callers holding
|
||||
// no viewer record use it to answer "is this Emby's user or Memby's" cheaply.
|
||||
func IsShadowViewerID(id string) bool {
|
||||
return strings.HasPrefix(id, "v") && len(id) == 33
|
||||
}
|
||||
|
||||
// Viewers lists an account's viewers, main first and the rest in the order they were
|
||||
// added. The main viewer is created on demand: an account that predates this feature has
|
||||
// no row, and its first request must still resolve to something rather than to an error.
|
||||
func (s *Store) Viewers(ctx context.Context, embyUserID, username string) ([]Viewer, error) {
|
||||
if err := s.ensureMainViewer(ctx, embyUserID, username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
|
||||
FROM viewers WHERE emby_user_id = $1
|
||||
ORDER BY kind = 'main' DESC, created_at, id`, embyUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list viewers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
viewers := []Viewer{}
|
||||
for rows.Next() {
|
||||
var v Viewer
|
||||
if err := rows.Scan(
|
||||
&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan viewer: %w", err)
|
||||
}
|
||||
viewers = append(viewers, v)
|
||||
}
|
||||
return viewers, rows.Err()
|
||||
}
|
||||
|
||||
// ensureMainViewer records the account's own viewer if it has none.
|
||||
//
|
||||
// The insert is ON CONFLICT DO NOTHING on the primary key, so two televisions signing in
|
||||
// at once cannot both create it, and the name is only ever set on the way in: the viewer
|
||||
// may have been renamed since, and an Emby username arriving on every request must not
|
||||
// overwrite that.
|
||||
func (s *Store) ensureMainViewer(ctx context.Context, embyUserID, username string) error {
|
||||
if strings.TrimSpace(embyUserID) == "" {
|
||||
return fmt.Errorf("store: main viewer: no account")
|
||||
}
|
||||
name := strings.TrimSpace(username)
|
||||
if name == "" {
|
||||
name = "Me"
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO viewers (id, emby_user_id, name, kind)
|
||||
VALUES ($1, $1, $2, 'main')
|
||||
ON CONFLICT (id) DO NOTHING`, embyUserID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: ensure main viewer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ViewerFor resolves one viewer *of this account*.
|
||||
//
|
||||
// The account is part of the query rather than checked afterwards: the id arrives in a
|
||||
// request header, so this is the boundary at which one household's television is stopped
|
||||
// from naming another household's viewer.
|
||||
func (s *Store) ViewerFor(ctx context.Context, embyUserID, viewerID string) (Viewer, error) {
|
||||
var v Viewer
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
|
||||
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID,
|
||||
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Viewer{}, ErrViewerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Viewer{}, fmt.Errorf("store: viewer: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// CreateShadowViewer adds a person to an account.
|
||||
//
|
||||
// The count is taken inside the transaction, because the limit is the only thing standing
|
||||
// between a held D-pad on the add button and an unbounded picker.
|
||||
func (s *Store) CreateShadowViewer(
|
||||
ctx context.Context, embyUserID, name, shortName, colour string,
|
||||
) (Viewer, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Viewer{}, fmt.Errorf("store: viewer name is required")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Viewer{}, fmt.Errorf("store: begin create viewer: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var shadows int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM viewers WHERE emby_user_id = $1 AND kind = 'shadow'`,
|
||||
embyUserID,
|
||||
).Scan(&shadows); err != nil {
|
||||
return Viewer{}, fmt.Errorf("store: count viewers: %w", err)
|
||||
}
|
||||
if shadows >= MaxShadowViewers {
|
||||
return Viewer{}, fmt.Errorf("store: %d viewers is the limit", MaxShadowViewers)
|
||||
}
|
||||
|
||||
id, err := NewShadowViewerID()
|
||||
if err != nil {
|
||||
return Viewer{}, err
|
||||
}
|
||||
var v Viewer
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO viewers (id, emby_user_id, name, short_name, colour, kind)
|
||||
VALUES ($1, $2, $3, $4, $5, 'shadow')
|
||||
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
|
||||
id, embyUserID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
|
||||
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt); err != nil {
|
||||
return Viewer{}, fmt.Errorf("store: create viewer: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Viewer{}, fmt.Errorf("store: commit create viewer: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// UpdateShadowViewer renames or re-colours a viewer. The main viewer is deliberately not
|
||||
// updatable here: its name is the Emby account's and belongs to Emby.
|
||||
func (s *Store) UpdateShadowViewer(
|
||||
ctx context.Context, embyUserID, viewerID, name, shortName, colour string,
|
||||
) (Viewer, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Viewer{}, fmt.Errorf("store: viewer name is required")
|
||||
}
|
||||
var v Viewer
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE viewers SET name = $3, short_name = $4, colour = $5, updated_at = now()
|
||||
WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'
|
||||
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
|
||||
embyUserID, viewerID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
|
||||
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Viewer{}, ErrViewerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Viewer{}, fmt.Errorf("store: update viewer: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// DeleteShadowViewer removes a viewer and everything Memby held on their behalf.
|
||||
//
|
||||
// A main viewer can never be deleted through this route: it is the account's own, and an
|
||||
// account with no main viewer would have nothing to fall back to. The playback state goes
|
||||
// with the row rather than being left to a housekeeping task, because the whole of what it
|
||||
// describes is a person who no longer exists.
|
||||
func (s *Store) DeleteShadowViewer(ctx context.Context, embyUserID, viewerID string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin delete viewer: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM viewers WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'`,
|
||||
embyUserID, viewerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete viewer: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrViewerNotFound
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM viewer_playback_state WHERE viewer_id = $1`, viewerID); err != nil {
|
||||
return fmt.Errorf("store: delete viewer state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("store: commit delete viewer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A shadow id must never be mistaken for an Emby user id: the two are substituted for one
|
||||
// another in twenty tables that cannot tell the difference, so telling them apart by
|
||||
// inspection is the safety property the whole scheme rests on.
|
||||
func TestShadowViewerIDIsDistinguishableFromEmbyUserID(t *testing.T) {
|
||||
id, err := NewShadowViewerID()
|
||||
if err != nil {
|
||||
t.Fatalf("mint shadow id: %v", err)
|
||||
}
|
||||
if !IsShadowViewerID(id) {
|
||||
t.Fatalf("minted id %q not recognised as a shadow id", id)
|
||||
}
|
||||
// Emby's are 32 hex characters with no prefix.
|
||||
if IsShadowViewerID("8f14e45fceea167a5a36dedd4bea2543") {
|
||||
t.Fatal("an Emby user id was read as a shadow viewer")
|
||||
}
|
||||
if IsShadowViewerID("") || IsShadowViewerID("v") || IsShadowViewerID("viewer") {
|
||||
t.Fatal("a short string was read as a shadow viewer")
|
||||
}
|
||||
other, err := NewShadowViewerID()
|
||||
if err != nil {
|
||||
t.Fatalf("mint second shadow id: %v", err)
|
||||
}
|
||||
if other == id {
|
||||
t.Fatal("two minted ids collided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayedFromPosition(t *testing.T) {
|
||||
const hour = int64(36_000_000_000) // one hour in Emby ticks
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
position int64
|
||||
runtime int64
|
||||
want bool
|
||||
}{
|
||||
{"finished", hour, hour, true},
|
||||
{"at the threshold", hour * 9 / 10, hour, true},
|
||||
{"just short of it", hour*9/10 - 1, hour, false},
|
||||
{"barely started", hour / 100, hour, false},
|
||||
// A runtime of zero means the length was not known, not that the title is zero
|
||||
// long. Reading it the other way marks everything watched at the first second.
|
||||
{"unknown runtime", hour, 0, false},
|
||||
{"nothing watched", 0, hour, false},
|
||||
{"negative position", -hour, hour, false},
|
||||
// Playing past the stated runtime is ordinary — a container whose duration is a
|
||||
// little short of its own last frame.
|
||||
{"past the end", hour * 2, hour, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := PlayedFromPosition(tc.position, tc.runtime); got != tc.want {
|
||||
t.Fatalf("PlayedFromPosition(%d, %d) = %v, want %v",
|
||||
tc.position, tc.runtime, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The rollup a series card's tick and count are built from. It is done in Go rather than in
|
||||
// SQL — the query groups by the season/series pair and this folds it twice — so it is worth
|
||||
// pinning that both directions add up and that the date is the latest of them.
|
||||
func TestViewerAggregateRollup(t *testing.T) {
|
||||
earlier := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC)
|
||||
later := time.Date(2026, 8, 18, 21, 30, 0, 0, time.UTC)
|
||||
aggregates := map[string]ViewerAggregate{}
|
||||
|
||||
// Two seasons of one show, folded into the series and kept apart per season.
|
||||
addViewerAggregate(aggregates, "show-1", 10, 10, &earlier)
|
||||
addViewerAggregate(aggregates, "season-1", 10, 10, &earlier)
|
||||
addViewerAggregate(aggregates, "show-1", 8, 3, &later)
|
||||
addViewerAggregate(aggregates, "season-2", 8, 3, &later)
|
||||
|
||||
series := aggregates["show-1"]
|
||||
if series.Total != 18 || series.Played != 13 {
|
||||
t.Errorf("series rollup = %d of %d, want 13 of 18", series.Played, series.Total)
|
||||
}
|
||||
if series.LastPlayedAt == nil || !series.LastPlayedAt.Equal(later) {
|
||||
t.Errorf("series last played = %v, want the later of the two", series.LastPlayedAt)
|
||||
}
|
||||
if got := aggregates["season-1"]; got.Total != 10 || got.Played != 10 {
|
||||
t.Errorf("season one = %d of %d, want 10 of 10", got.Played, got.Total)
|
||||
}
|
||||
if got := aggregates["season-2"]; got.Total != 8 || got.Played != 3 {
|
||||
t.Errorf("season two = %d of %d, want 3 of 8", got.Played, got.Total)
|
||||
}
|
||||
|
||||
// An episode filed under no series or no season contributes to neither, rather than to
|
||||
// a row keyed on the empty string — which would be an aggregate about nothing.
|
||||
addViewerAggregate(aggregates, "", 5, 5, &later)
|
||||
if _, ok := aggregates[""]; ok {
|
||||
t.Error("an unfiled episode produced an aggregate")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user