Memby v0.1.53: Android TV client plus gateway
Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// LibraryItem is one imported Emby item. Payload is Emby's JSON verbatim; the flat
|
||||
// columns exist only so Postgres can filter and rank without opening the JSON.
|
||||
type LibraryItem struct {
|
||||
ID string
|
||||
Type string
|
||||
Name string
|
||||
SeriesID string
|
||||
SeriesName string
|
||||
ProductionYear *int
|
||||
CommunityRating *float64
|
||||
Genres []string
|
||||
Studios []string
|
||||
DateCreated *time.Time
|
||||
SearchText string
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
// LibraryStats is what the admin page shows about the imported library.
|
||||
type LibraryStats struct {
|
||||
Total int64 `json:"total"`
|
||||
ByType map[string]int64 `json:"byType"`
|
||||
LastSynced *time.Time `json:"lastSynced"`
|
||||
}
|
||||
|
||||
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
|
||||
//
|
||||
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
|
||||
// sees, then deletes whatever kept an older stamp.
|
||||
func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syncedAt time.Time) (int64, error) {
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
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`,
|
||||
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)
|
||||
}
|
||||
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
|
||||
var written int64
|
||||
for range items {
|
||||
tag, err := results.Exec()
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("store: upsert library items: %w", err)
|
||||
}
|
||||
written += tag.RowsAffected()
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
|
||||
// from Emby since the last run.
|
||||
func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM library_items WHERE synced_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: prune library: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// SearchLibrary answers from the imported library rather than Emby.
|
||||
//
|
||||
// Full-text match first, with a trailing ILIKE so partial words ("sever") still hit
|
||||
// before someone finishes typing on a remote.
|
||||
func (s *Store) SearchLibrary(ctx context.Context, term string, limit int) ([]json.RawMessage, error) {
|
||||
trimmed := strings.TrimSpace(term)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE search_tsv @@ plainto_tsquery('simple', $1)
|
||||
OR search_text ILIKE '%' || $1 || '%'
|
||||
ORDER BY
|
||||
ts_rank(search_tsv, plainto_tsquery('simple', $1)) DESC,
|
||||
(lower(name) = lower($1)) DESC,
|
||||
community_rating DESC NULLS LAST,
|
||||
name ASC
|
||||
LIMIT $2`, trimmed, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: search library: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) {
|
||||
if len(genres) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE type IN ('Movie', 'Series')
|
||||
AND genres && $1
|
||||
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
|
||||
LIMIT $2`, genres, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library candidates: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
|
||||
stats := LibraryStats{ByType: map[string]int64{}}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `SELECT type, count(*) FROM library_items GROUP BY type`)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("store: library stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var itemType string
|
||||
var count int64
|
||||
if err := rows.Scan(&itemType, &count); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.ByType[itemType] = count
|
||||
stats.Total += count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
var lastSynced *time.Time
|
||||
if err := s.pool.QueryRow(ctx, `SELECT max(synced_at) FROM library_items`).Scan(&lastSynced); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.LastSynced = lastSynced
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func collectPayloads(rows pgx.Rows) ([]json.RawMessage, error) {
|
||||
defer rows.Close()
|
||||
out := []json.RawMessage{}
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, json.RawMessage(payload))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user