0.2.54
This commit is contained in:
@@ -59,7 +59,7 @@ type RowEvent struct {
|
||||
}
|
||||
|
||||
// JourneyEvent is one significant step through the app. All descriptive fields are
|
||||
// controlled vocabulary; ItemID is the only content identity retained.
|
||||
// controlled vocabulary; ItemName is the only free-text content context retained.
|
||||
type JourneyEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
@@ -72,7 +72,7 @@ type JourneyEvent struct {
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
ItemName string `json:"itemName,omitempty"`
|
||||
ItemType string `json:"itemType,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
@@ -211,12 +211,12 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
|
||||
batch.Queue(`
|
||||
INSERT INTO journey_events
|
||||
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
||||
feature, source, target, item_id, item_type, outcome)
|
||||
feature, source, target, item_name, item_type, outcome)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
|
||||
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
||||
event.Category, event.Action, event.Screen, event.Feature, event.Source,
|
||||
event.Target, event.ItemID, event.ItemType, event.Outcome)
|
||||
event.Target, event.ItemName, event.ItemType, event.Outcome)
|
||||
}
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
@@ -369,7 +369,7 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
|
||||
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
|
||||
screen, feature, source, target, item_id, item_type, outcome
|
||||
screen, feature, source, target, item_name, item_type, outcome
|
||||
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
||||
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
||||
if err != nil {
|
||||
@@ -379,7 +379,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
|
||||
out := []JourneyEvent{}
|
||||
for rows.Next() {
|
||||
var v JourneyEvent
|
||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemType, &v.Outcome); err != nil {
|
||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemName, &v.ItemType, &v.Outcome); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
|
||||
@@ -146,6 +146,40 @@ func (s *Store) SearchLibrary(ctx context.Context, term string, limit int) ([]js
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// LibraryContainsProviderIDs reports which external catalogue ids are already represented in
|
||||
// Emby. ProviderIds stays inside the imported payload because it is not a ranking field;
|
||||
// request autocomplete is the one place that needs to compare it with Radarr/Sonarr.
|
||||
func (s *Store) LibraryContainsProviderIDs(
|
||||
ctx context.Context, provider string, ids []int,
|
||||
) (map[int]bool, error) {
|
||||
found := map[int]bool{}
|
||||
if len(ids) == 0 {
|
||||
return found, nil
|
||||
}
|
||||
values := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
values = append(values, fmt.Sprint(id))
|
||||
}
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT (payload->'ProviderIds'->>$1)::int
|
||||
FROM library_items
|
||||
WHERE payload->'ProviderIds'->>$1 = ANY($2::text[])`, provider, values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library provider ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found[id] = true
|
||||
}
|
||||
return found, rows.Err()
|
||||
}
|
||||
|
||||
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
|
||||
// recommendation engine. User state is applied by the caller, which is the only place
|
||||
// that knows it.
|
||||
|
||||
@@ -108,9 +108,9 @@ func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error
|
||||
return shows, rows.Err()
|
||||
}
|
||||
|
||||
// RecordSonarrSeriesStatuses appends only first sightings and changes. A first sighting is
|
||||
// the baseline and is deliberately absent from the returned changes, so enabling the
|
||||
// scanner cannot announce every series that was already cancelled before it existed.
|
||||
// RecordSonarrSeriesStatuses appends first sightings and changes. The first complete scan
|
||||
// seeds a baseline; a first sighting on later scans is returned with an empty previous
|
||||
// status so the lifecycle scanner can announce a show newly added to Sonarr.
|
||||
func (s *Store) RecordSonarrSeriesStatuses(
|
||||
ctx context.Context, observations []SonarrSeriesStatus,
|
||||
) ([]SonarrSeriesStatusChange, error) {
|
||||
@@ -119,6 +119,10 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
||||
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var seeded bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM sonarr_lifecycle_scan_state)`).Scan(&seeded); err != nil {
|
||||
return nil, fmt.Errorf("store: read Sonarr lifecycle seed: %w", err)
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT DISTINCT ON (series_key) series_key, status
|
||||
@@ -172,13 +176,18 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
|
||||
}
|
||||
if known {
|
||||
if known || seeded {
|
||||
changes = append(changes, SonarrSeriesStatusChange{
|
||||
HistoryID: historyID, PreviousStatus: prior, Current: observation,
|
||||
})
|
||||
}
|
||||
previous[observation.SeriesKey] = observation.Status
|
||||
}
|
||||
if !seeded {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO sonarr_lifecycle_scan_state (singleton) VALUES (true) ON CONFLICT DO NOTHING`); err != nil {
|
||||
return nil, fmt.Errorf("store: seed Sonarr lifecycle scan: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@ CREATE TABLE IF NOT EXISTS row_events (
|
||||
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
|
||||
|
||||
-- Significant, user-scoped app journeys. Values are deliberately categorical: content
|
||||
-- names, search terms, setting values and other free text do not belong in this table.
|
||||
-- Significant, user-scoped app journeys. Item names make content events recognisable;
|
||||
-- search terms, setting values and other arbitrary free text do not belong in this table.
|
||||
-- journey_id is generated by the client for one foreground visit; emby_user_id is always
|
||||
-- taken from the authenticated gateway session rather than trusted from the payload.
|
||||
CREATE TABLE IF NOT EXISTS journey_events (
|
||||
@@ -172,10 +172,13 @@ CREATE TABLE IF NOT EXISTS journey_events (
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
item_name TEXT NOT NULL DEFAULT '',
|
||||
item_type TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
|
||||
ON journey_events (emby_user_id, journey_id, sequence);
|
||||
CREATE INDEX IF NOT EXISTS journey_events_user_time_idx
|
||||
@@ -256,6 +259,13 @@ CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
|
||||
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
|
||||
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
|
||||
|
||||
-- Separates the scanner's first baseline from a genuinely new series discovered later.
|
||||
-- A dedicated marker also handles an initially empty Sonarr library correctly.
|
||||
CREATE TABLE IF NOT EXISTS sonarr_lifecycle_scan_state (
|
||||
singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
|
||||
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
|
||||
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
|
||||
|
||||
Reference in New Issue
Block a user