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

386 lines
12 KiB
Go
Raw Normal View History

// Package library imports Emby's catalogue into Postgres so the gateway can answer from
// its own copy instead of asking Emby on every request.
//
// Two shapes of import:
//
// - **full** — page through everything, then delete whatever the pass did not touch.
// Run once to seed, and again whenever the library has been reorganised.
// - **incremental** — ask Emby only for items changed since the last successful run.
// Cheap enough to run hourly, which is what new episodes need; films appearing
// weekly are picked up by the same pass.
//
// Only shared metadata is imported (EnableUserData=false). Watched state, favourites and
// resume positions are per-user and stay live.
package library
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
// pageSize balances round trips against Emby's response size. 500 items of metadata
// is roughly a megabyte of JSON.
pageSize = 500
// syncFields is everything the gateway serves or filters on. Images are requested as
// tags only — the artwork itself is proxied on demand.
2026-08-06 22:33:56 +12:00
//
// ProviderIds is here for external ratings: it is what a title is called at MDBList,
// and reading it from the import means a home row resolves forty cards from one
// indexed query instead of asking Emby about each of them.
syncFields = "Genres,Studios,Overview,Taglines,ProductionYear,CommunityRating,OfficialRating," +
2026-08-02 22:10:19 +12:00
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,PremiereDate,People," +
2026-08-06 22:33:56 +12:00
"CollectionName,MediaStreams,RecursiveItemCount,ProviderIds"
syncImageTypes = "Backdrop,Primary,Logo,Thumb"
syncItemTypes = "Movie,Series,Episode"
)
// ErrNoCredentials means nothing has ever signed in and no service account is set, so
// there is no way to talk to Emby on the library's behalf.
var ErrNoCredentials = errors.New("library: no emby credentials available for sync")
type Syncer struct {
emby *emby.Client
store *store.Store
log *slog.Logger
// serviceCred is the optional configured account. When empty, the newest TV session
// is borrowed instead.
serviceCred emby.Credentials
2026-07-29 15:26:27 +12:00
mu sync.Mutex
running bool
2026-08-02 22:10:19 +12:00
afterSync func(Result)
}
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log}
}
2026-07-29 15:26:27 +12:00
// SetAfterSync installs the inexpensive invalidation callback used by derived data.
2026-08-02 22:10:19 +12:00
func (s *Syncer) SetAfterSync(callback func(Result)) {
2026-07-29 15:26:27 +12:00
s.mu.Lock()
defer s.mu.Unlock()
s.afterSync = callback
}
// Result summarises one import.
type Result struct {
Kind string `json:"kind"`
Seen int `json:"seen"`
Upserted int `json:"upserted"`
2026-08-02 22:10:19 +12:00
Changed int `json:"changed"`
Removed int `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
}
// Running reports whether an import is in flight, so the admin page can disable its
// buttons and the scheduler can skip a tick.
func (s *Syncer) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// Sync runs an import. kind is "full" or "incremental"; an incremental run with no prior
// successful sync silently upgrades itself to a full one, because there is no watermark
// to work from.
func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error) {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return Result{}, errors.New("library: a sync is already running")
}
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
cred, err := s.credentials(ctx)
if err != nil {
return Result{}, err
}
var since *time.Time
if kind == "incremental" {
since, err = s.store.LastSuccessfulSyncAt(ctx)
if err != nil {
return Result{}, err
}
if since == nil {
s.log.Info("no previous sync; upgrading to a full import")
kind = "full"
}
}
startedAt := time.Now().UTC()
runID, err := s.store.StartSyncRun(ctx, kind, trigger)
if err != nil {
return Result{}, err
}
2026-07-27 21:06:51 +12:00
s.log.Info("library sync started", "kind", kind, "trigger", trigger)
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
result.Kind = kind
result.Duration = time.Since(startedAt)
result.DurationMs = result.Duration.Milliseconds()
record := store.SyncRun{
Status: store.SyncStatusSuccess,
ItemsSeen: result.Seen,
ItemsUpserted: result.Upserted,
ItemsRemoved: result.Removed,
}
if syncErr != nil {
record.Status = store.SyncStatusFailed
record.Error = syncErr.Error()
}
// Always record the outcome, even when the caller's context died mid-import.
if err := s.store.FinishSyncRun(context.WithoutCancel(ctx), runID, record); err != nil {
s.log.Error("could not record sync run", "error", err)
}
if syncErr != nil {
return result, syncErr
}
s.log.Info("library sync finished",
"kind", kind, "trigger", trigger, "seen", result.Seen,
2026-08-02 22:10:19 +12:00
"upserted", result.Upserted, "changed", result.Changed, "removed", result.Removed,
2026-07-27 21:06:51 +12:00
"duration", result.Duration.Round(time.Millisecond))
2026-07-29 15:26:27 +12:00
s.mu.Lock()
afterSync := s.afterSync
s.mu.Unlock()
if afterSync != nil {
2026-08-02 22:10:19 +12:00
afterSync(result)
2026-07-29 15:26:27 +12:00
}
return result, nil
}
func (s *Syncer) run(
ctx context.Context,
cred emby.Credentials,
kind string,
since *time.Time,
syncedAt time.Time,
) (Result, error) {
var result Result
for startIndex := 0; ; startIndex += pageSize {
params := url.Values{
"IncludeItemTypes": {syncItemTypes},
"Recursive": {"true"},
"SortBy": {"DateCreated"},
"SortOrder": {"Ascending"},
"StartIndex": {strconv.Itoa(startIndex)},
"Limit": {strconv.Itoa(pageSize)},
"Fields": {syncFields},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {syncImageTypes},
"EnableTotalRecordCount": {"false"},
// The imported copy is shared by every user, so it must not carry one
// user's watched/favourite state.
"EnableUserData": {"false"},
}
if since != nil {
// Emby returns items created or edited after this instant. Overlap by a
// minute so an item saved during the previous run is not missed.
params.Set("MinDateLastSaved", since.Add(-time.Minute).UTC().Format(time.RFC3339))
}
page, err := s.emby.Items(ctx, cred, params)
if err != nil {
return result, fmt.Errorf("library: fetch page at %d: %w", startIndex, err)
}
if len(page.Items) == 0 {
break
}
items := make([]store.LibraryItem, 0, len(page.Items))
for _, raw := range page.Items {
if item, ok := toLibraryItem(raw); ok {
items = append(items, item)
}
}
2026-08-02 22:10:19 +12:00
changed, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
if err != nil {
return result, err
}
result.Seen += len(page.Items)
2026-08-02 22:10:19 +12:00
result.Upserted += len(items)
result.Changed += int(changed)
2026-07-27 21:06:51 +12:00
s.log.Info("library sync progress",
"kind", kind,
"seen", result.Seen,
"upserted", result.Upserted,
2026-08-02 22:10:19 +12:00
"changed", result.Changed,
2026-07-27 21:06:51 +12:00
)
if len(page.Items) < pageSize {
break
}
}
// Only a full pass has seen everything, so only a full pass may delete.
if kind == "full" {
removed, err := s.store.DeleteLibraryItemsBefore(ctx, syncedAt)
if err != nil {
return result, err
}
result.Removed = int(removed)
}
return result, nil
}
// credentials prefers the configured service account and otherwise borrows the most
// recent TV session.
func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
if s.serviceCred.Token != "" && s.serviceCred.UserID != "" {
return s.serviceCred, nil
}
sess, err := s.store.NewestSession(ctx)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return emby.Credentials{}, ErrNoCredentials
}
return emby.Credentials{}, err
}
return emby.Credentials{
UserID: sess.EmbyUserID,
Token: sess.EmbyToken,
DeviceID: "memby-gateway-sync",
2026-08-12 13:08:53 +12:00
// The token is borrowed from a television, but the import is the gateway's own
// work and must not appear in Emby's device list as that set.
Gateway: true,
}, nil
}
// Schedule runs an incremental import on an interval until ctx is cancelled.
//
// New episodes tend to land through the day and films weekly; an hourly incremental pass
// covers both without ever asking Emby for the whole catalogue again.
2026-08-17 07:34:23 +12:00
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration, paused ...func() bool) {
if interval <= 0 {
s.log.Info("library auto-sync disabled")
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.log.Info("library auto-sync scheduled", "interval", interval.String())
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
2026-08-17 07:34:23 +12:00
if len(paused) > 0 && paused[0] != nil && paused[0]() {
s.log.Debug("skipping scheduled sync; quiet time is active")
continue
}
if s.Running() {
s.log.Info("skipping scheduled sync; one is already running")
continue
}
if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil {
if errors.Is(err, ErrNoCredentials) {
// Nobody has signed in yet. Not worth an error-level log every hour.
s.log.Info("skipping scheduled sync; no credentials yet")
continue
}
s.log.Error("scheduled sync failed", "error", err)
}
}
}
}
// syncItem mirrors the Emby fields promoted to columns.
type syncItem struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
ProductionYear *int `json:"ProductionYear"`
CommunityRating *float64 `json:"CommunityRating"`
Genres []string `json:"Genres"`
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
DateCreated *time.Time `json:"DateCreated"`
}
// toLibraryItem flattens the columns Postgres filters on while keeping the payload whole.
func toLibraryItem(raw json.RawMessage) (store.LibraryItem, bool) {
var parsed syncItem
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.ID == "" {
return store.LibraryItem{}, false
}
studios := make([]string, 0, len(parsed.Studios))
for _, studio := range parsed.Studios {
if name := strings.TrimSpace(studio.Name); name != "" {
studios = append(studios, name)
}
}
genres := make([]string, 0, len(parsed.Genres))
for _, genre := range parsed.Genres {
if g := strings.TrimSpace(genre); g != "" {
genres = append(genres, g)
}
}
return store.LibraryItem{
ID: parsed.ID,
Type: parsed.Type,
Name: parsed.Name,
SeriesID: parsed.SeriesID,
SeriesName: parsed.SeriesName,
ProductionYear: parsed.ProductionYear,
CommunityRating: parsed.CommunityRating,
Genres: genres,
Studios: studios,
DateCreated: parsed.DateCreated,
SearchText: searchText(parsed),
Payload: raw,
}, true
}
// searchText is what full-text search matches against. Series name is included so
// searching a show finds its episodes.
func searchText(parsed syncItem) string {
parts := []string{parsed.Name}
if parsed.SeriesName != "" && !strings.EqualFold(parsed.SeriesName, parsed.Name) {
parts = append(parts, parsed.SeriesName)
}
if parsed.ProductionYear != nil {
parts = append(parts, strconv.Itoa(*parsed.ProductionYear))
}
parts = append(parts, parsed.Genres...)
2026-07-29 15:26:27 +12:00
// Studios too, so "A24" or "Pixar" finds a shelf's worth of titles. Existing rows
// keep their old text until the next full import rewrites them.
for _, studio := range parsed.Studios {
if studio.Name != "" {
parts = append(parts, studio.Name)
}
}
return strings.Join(parts, " ")
}