Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+109 -24
View File
@@ -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 {