509 lines
19 KiB
Go
509 lines
19 KiB
Go
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
|
||
|
|
}
|