Publish current app and server
This commit is contained in:
@@ -34,7 +34,8 @@ type LibraryStats struct {
|
||||
LastSynced *time.Time `json:"lastSynced"`
|
||||
}
|
||||
|
||||
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
|
||||
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches, and
|
||||
// returns how many recommendation-relevant payloads were inserted or actually changed.
|
||||
//
|
||||
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
|
||||
// sees, then deletes whatever kept an older stamp.
|
||||
@@ -46,23 +47,48 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
|
||||
batch := &pgx.Batch{}
|
||||
for _, item := range items {
|
||||
batch.Queue(`
|
||||
INSERT INTO library_items (
|
||||
id, type, name, series_id, series_name, production_year, community_rating,
|
||||
genres, studios, date_created, search_text, payload, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
name = EXCLUDED.name,
|
||||
series_id = EXCLUDED.series_id,
|
||||
series_name = EXCLUDED.series_name,
|
||||
production_year = EXCLUDED.production_year,
|
||||
community_rating = EXCLUDED.community_rating,
|
||||
genres = EXCLUDED.genres,
|
||||
studios = EXCLUDED.studios,
|
||||
date_created = EXCLUDED.date_created,
|
||||
search_text = EXCLUDED.search_text,
|
||||
payload = EXCLUDED.payload,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
WITH previous AS MATERIALIZED (
|
||||
SELECT type, name, series_id, series_name, production_year,
|
||||
community_rating, genres, studios, date_created,
|
||||
payload->'RunTimeTicks' AS runtime_ticks,
|
||||
payload->'MediaStreams' AS media_streams,
|
||||
payload->'Container' AS container
|
||||
FROM library_items WHERE id = $1
|
||||
), upserted AS (
|
||||
INSERT INTO library_items (
|
||||
id, type, name, series_id, series_name, production_year, community_rating,
|
||||
genres, studios, date_created, search_text, payload, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
name = EXCLUDED.name,
|
||||
series_id = EXCLUDED.series_id,
|
||||
series_name = EXCLUDED.series_name,
|
||||
production_year = EXCLUDED.production_year,
|
||||
community_rating = EXCLUDED.community_rating,
|
||||
genres = EXCLUDED.genres,
|
||||
studios = EXCLUDED.studios,
|
||||
date_created = EXCLUDED.date_created,
|
||||
search_text = EXCLUDED.search_text,
|
||||
payload = EXCLUDED.payload,
|
||||
synced_at = EXCLUDED.synced_at
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT NOT EXISTS (SELECT 1 FROM previous)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM previous
|
||||
WHERE ROW(
|
||||
type, name, series_id, series_name, production_year,
|
||||
community_rating, genres, studios, date_created,
|
||||
runtime_ticks, media_streams, container
|
||||
) IS DISTINCT FROM ROW(
|
||||
$2::text, $3::text, $4::text, $5::text, $6::int,
|
||||
$7::real, $8::text[], $9::text[], $10::timestamptz,
|
||||
$12::jsonb->'RunTimeTicks', $12::jsonb->'MediaStreams',
|
||||
$12::jsonb->'Container'
|
||||
)
|
||||
)
|
||||
FROM upserted`,
|
||||
item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName,
|
||||
item.ProductionYear, item.CommunityRating, item.Genres, item.Studios,
|
||||
item.DateCreated, item.SearchText, string(item.Payload), syncedAt)
|
||||
@@ -71,15 +97,17 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
|
||||
var written int64
|
||||
var changed int64
|
||||
for range items {
|
||||
tag, err := results.Exec()
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("store: upsert library items: %w", err)
|
||||
var recommendationChanged bool
|
||||
if err := results.QueryRow().Scan(&recommendationChanged); err != nil {
|
||||
return changed, fmt.Errorf("store: upsert library items: %w", err)
|
||||
}
|
||||
if recommendationChanged {
|
||||
changed++
|
||||
}
|
||||
written += tag.RowsAffected()
|
||||
}
|
||||
return written, nil
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
|
||||
@@ -153,6 +181,23 @@ func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMess
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
func (s *Store) LibraryItemsByID(
|
||||
ctx context.Context,
|
||||
ids []string,
|
||||
) ([]json.RawMessage, error) {
|
||||
if len(ids) == 0 {
|
||||
return []json.RawMessage{}, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library items by id: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
|
||||
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
||||
func (s *Store) CuratedCandidates(
|
||||
@@ -190,6 +235,46 @@ func (s *Store) CuratedCandidates(
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// LibraryGenres returns every genre with enough catalogue depth to make a shelf feel
|
||||
// intentional. The recommendation engine still applies per-user seen filtering and may
|
||||
// drop a shelf afterwards when too few unseen titles remain.
|
||||
func (s *Store) LibraryGenres(
|
||||
ctx context.Context,
|
||||
itemTypes []string,
|
||||
minItems int,
|
||||
) ([]string, error) {
|
||||
if len(itemTypes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if minItems < 1 {
|
||||
minItems = 1
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT genre, count(DISTINCT item.id) AS item_count
|
||||
FROM library_items AS item
|
||||
CROSS JOIN LATERAL unnest(item.genres) AS genre
|
||||
WHERE item.type = ANY($1)
|
||||
AND btrim(genre) <> ''
|
||||
GROUP BY genre
|
||||
HAVING count(DISTINCT item.id) >= $2
|
||||
ORDER BY item_count DESC, lower(genre) ASC`,
|
||||
itemTypes, minItems)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library genres: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var genre string
|
||||
var count int
|
||||
if err := rows.Scan(&genre, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, genre)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func lowerStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
Reference in New Issue
Block a user