Files
memby/server/internal/store/library.go
T

227 lines
6.6 KiB
Go
Raw Normal View History

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)
}
2026-07-27 21:06:51 +12:00
// 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(
ctx context.Context,
itemTypes, genres, studios []string,
limit int,
) ([]json.RawMessage, error) {
if len(itemTypes) == 0 || (len(genres) == 0 && len(studios) == 0) {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE type = ANY($1)
AND (
cardinality($2::text[]) = 0 OR
EXISTS (
SELECT 1 FROM unnest(genres) AS genre
WHERE lower(genre) = ANY($2)
)
)
AND (
cardinality($3::text[]) = 0 OR
EXISTS (
SELECT 1 FROM unnest(studios) AS studio
WHERE lower(studio) = ANY($3)
)
)
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
LIMIT $4`,
itemTypes, lowerStrings(genres), lowerStrings(studios), limit)
if err != nil {
return nil, fmt.Errorf("store: curated candidates: %w", err)
}
return collectPayloads(rows)
}
func lowerStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, strings.ToLower(strings.TrimSpace(value)))
}
return out
}
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()
}