This commit is contained in:
ponzischeme89
2026-08-12 08:25:15 +12:00
parent e30962245e
commit 4b31946635
28 changed files with 439 additions and 130 deletions
+6 -6
View File
@@ -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)
+34
View File
@@ -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.
+13 -4
View File
@@ -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)
}
+12 -2
View File
@@ -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.