0.2.76 - Icon Packs

This commit is contained in:
ponzischeme89
2026-08-18 14:59:29 +12:00
parent 36d171e51b
commit 8c847c59b8
70 changed files with 5267 additions and 673 deletions
+740
View File
@@ -0,0 +1,740 @@
package library
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The worker that drains what Sonarr and Radarr told us.
//
// The scheduled import asks Emby "what has changed since an hour ago" and pages through
// the answer. This asks Emby "where is this one episode", which is a request whose size
// does not grow with the library, and it asks only because something that actually puts
// files on disk said there was a reason to.
//
// Emby is the *lookup* here and never the discovery mechanism. Nothing in this file
// enumerates a library, and the one thing that still does — Syncer.Schedule — is demoted
// to reconciliation for media the *arrs do not manage.
const (
// defaultSettleDelay is how long after a webhook the first attempt is made. Sonarr
// fires On Import the moment it has moved the file; Emby has not scanned it yet, and
// asking immediately would spend a request to learn that.
defaultSettleDelay = 60 * time.Second
// idlePoll is how often the worker looks for due work. Coarse on purpose: everything
// here is already late by a settle delay, and a tight loop against Postgres on an idle
// NAS is exactly the background cost this replaces.
idlePoll = 20 * time.Second
// claimBatch bounds one pass. A season pack arrives as a dozen notifications at once
// and there is no hurry: draining a few per pass keeps Emby's request rate flat.
claimBatch = 4
// maxAttempts is where a piece of work is given up on. With the backoff below that is
// most of a day, after which the item is the reconciliation sweep's problem — which is
// the honest answer, since something other than timing is wrong by then.
maxAttempts = 7
// jobBudget bounds one piece of work end to end.
jobBudget = 60 * time.Second
)
// IngestStore is the slice of the store this needs. Narrow so the whole worker can be
// exercised against maps in a test, and so it is visible at a glance that the only things
// it writes are catalogue rows and the queue's own state.
type IngestStore interface {
EnqueueIngest(ctx context.Context, job store.IngestJob) (bool, error)
ClaimIngest(ctx context.Context, now time.Time, limit int) ([]store.IngestJob, error)
FinishIngest(ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time) error
UpsertLibraryItems(ctx context.Context, items []store.LibraryItem, syncedAt time.Time) (int64, error)
DeleteLibraryItem(ctx context.Context, itemID string) (int64, error)
SeriesRefs(ctx context.Context) ([]store.SeriesRef, error)
CreditsSeriesEpisodes(ctx context.Context, seriesIDs []string) ([]store.CreditsEpisodeRow, error)
LibraryItemsByName(ctx context.Context, itemType, name string) ([]store.NamedItem, error)
}
// EmbySource is the slice of Emby this needs: two reads and one nudge.
type EmbySource interface {
Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
Episodes(ctx context.Context, cred emby.Credentials, seriesID string, params url.Values) (*emby.ItemsResult, error)
RefreshItem(ctx context.Context, cred emby.Credentials, itemID string) error
}
// Ingester drains the durable queue.
type Ingester struct {
Store IngestStore
Emby EmbySource
Credentials func(ctx context.Context) (emby.Credentials, error)
Log *slog.Logger
// Paused is the server-wide quiet-time gate. The queue is durable precisely so this can
// say no: a webhook that arrives during quiet hours is recorded and read afterwards,
// where the old arrangement answered it 503 and lost the event outright.
Paused func() bool
// Settle is the delay applied when work is enqueued. Held here so the hook and the
// worker cannot disagree about it.
Settle time.Duration
// Announce is told about a finished import, so the news reaches the televisions from
// the moment the title is actually there rather than from the moment the *arr said it
// would be. Installed from main.go, like syncer.SetAfterSync and for the same reason:
// library has no business knowing what an alert is. Nil is ordinary — a gateway with
// nothing to announce to, and every test in this package.
Announce func(ctx context.Context, result IngestResult)
}
func (i *Ingester) log() *slog.Logger {
if i == nil || i.Log == nil {
return slog.Default()
}
return i.Log
}
// SettleDelay is what the hook stamps onto a new row.
func (i *Ingester) SettleDelay() time.Duration {
if i == nil || i.Settle <= 0 {
return defaultSettleDelay
}
return i.Settle
}
// Run is the worker. One goroutine for the whole gateway.
func (i *Ingester) Run(ctx context.Context) {
if i == nil || i.Store == nil || i.Emby == nil || i.Credentials == nil {
return
}
i.log().Info("library ingest worker started", "settle", i.SettleDelay().String())
for {
if ctx.Err() != nil {
return
}
worked := false
if i.Paused == nil || !i.Paused() {
worked = i.drain(ctx)
}
if worked {
continue
}
if !sleep(ctx, idlePoll) {
return
}
}
}
// drain works everything currently due and reports whether it did anything, so a busy
// queue is emptied without waiting a poll interval between rows.
func (i *Ingester) drain(ctx context.Context) bool {
jobs, err := i.Store.ClaimIngest(ctx, time.Now().UTC(), claimBatch)
if err != nil {
if ctx.Err() == nil {
i.log().Warn("could not read the ingest queue", "error", err)
}
return false
}
if len(jobs) == 0 {
return false
}
for _, job := range jobs {
if ctx.Err() != nil {
return false
}
jobCtx, cancel := context.WithTimeout(ctx, jobBudget)
i.work(jobCtx, job)
cancel()
}
return true
}
// work is one row, start to finish. Every exit records an outcome, because the row *is*
// the operator's answer to "why was this item re-read, and did it work".
func (i *Ingester) work(ctx context.Context, job store.IngestJob) {
var request IngestRequest
if err := json.Unmarshal(job.Payload, &request); err != nil {
i.settle(ctx, job, store.IngestFailed, "invalid", "", err)
return
}
request.Key, request.Action = job.Key, job.Action
request.Kind, request.Reason = job.Kind, job.Reason
cred, err := i.Credentials(ctx)
if err != nil {
// Nobody has signed in yet, so there is no way to ask Emby anything. That is a
// deferral rather than a failure: the work is still valid, it simply cannot be
// done until a television signs in.
i.defer_(ctx, job, "no_credentials", err)
return
}
if job.Action == ActionRemove {
i.remove(ctx, job, request)
return
}
i.refresh(ctx, job, request, cred)
}
// refresh is the ordinary path: find the item in Emby and write it into the catalogue.
func (i *Ingester) refresh(
ctx context.Context, job store.IngestJob, request IngestRequest, cred emby.Credentials,
) {
items, itemID, err := i.resolve(ctx, request, cred)
if err != nil {
i.defer_(ctx, job, "lookup_failed", err)
return
}
if len(items) == 0 {
// Emby has not scanned the file in yet, which on a fresh import is the expected
// first answer rather than a fault. One nudge, then wait: the backoff is what turns
// "not yet" into "not ever" without a request per minute in between.
i.nudge(ctx, request, cred)
i.defer_(ctx, job, "not_found", nil)
return
}
written := make([]store.LibraryItem, 0, len(items))
for _, raw := range items {
if item, ok := toLibraryItem(raw); ok {
written = append(written, item)
}
}
if len(written) == 0 {
i.defer_(ctx, job, "not_found", nil)
return
}
// Stamped now, like any other import, so a title written here is never the victim of a
// full pass that happens to be running.
if _, err := i.Store.UpsertLibraryItems(ctx, written, time.Now().UTC()); err != nil {
i.defer_(ctx, job, "write_failed", err)
return
}
i.settle(ctx, job, store.IngestDone, "imported", itemID, nil)
i.log().Info("arr ingest",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", "imported", "items", len(written),
"item", itemID, "attempts", job.Attempts+1)
// After the row is recorded, never before: the announcement is a claim that the title
// is in the catalogue, and it must not be made by a pass that then failed to record it.
i.announce(ctx, job, request, written, itemID)
}
// announce reports a finished scan, if anybody is listening.
//
// Whether a given import is worth a banner is deliberately not decided here — that is a
// question about what viewers should be told, which belongs with the rest of the alert
// wording. This says what happened; the API package decides what to say about it.
func (i *Ingester) announce(
ctx context.Context, job store.IngestJob, request IngestRequest,
written []store.LibraryItem, itemID string,
) {
if i.Announce == nil {
return
}
result := IngestResult{
Source: job.Source,
Kind: job.Kind,
Reason: job.Reason,
ItemID: itemID,
SeriesName: request.Series,
Season: request.Season,
Episode: request.Episode,
Name: request.Title,
Year: request.Year,
}
// Emby's own record of the item outranks what the *arr called it: they disagree about
// punctuation and about years often enough that the banner and the card underneath it
// would otherwise name the same thing two ways.
if item, found := findWritten(written, itemID); found {
result.Name = item.Name
result.ImageTag = primaryImageTag(item.Payload)
if item.SeriesName != "" {
result.SeriesName = item.SeriesName
}
if item.ProductionYear != nil {
result.Year = *item.ProductionYear
}
}
i.Announce(ctx, result)
}
func findWritten(written []store.LibraryItem, itemID string) (store.LibraryItem, bool) {
if itemID == "" {
return store.LibraryItem{}, false
}
for _, item := range written {
if item.ID == itemID {
return item, true
}
}
return store.LibraryItem{}, false
}
// primaryImageTag digs the poster tag out of the payload that was just stored, so a banner
// can carry artwork without a second lookup. An absent tag is ordinary and costs nothing:
// the alert simply travels without one.
func primaryImageTag(payload json.RawMessage) string {
var parsed struct {
ImageTags map[string]string `json:"ImageTags"`
}
if json.Unmarshal(payload, &parsed) != nil {
return ""
}
return parsed.ImageTags["Primary"]
}
// remove takes a deleted title out of the catalogue.
//
// It resolves against the *local* catalogue rather than against Emby, which is the one
// place in this file that is deliberately the other way round: the thing being removed is
// a row in Memby's copy, and Emby — having had the file deleted underneath it — is the
// least likely place to still be able to name it.
func (i *Ingester) remove(ctx context.Context, job store.IngestJob, request IngestRequest) {
itemID, err := i.localItemID(ctx, request)
if err != nil {
i.defer_(ctx, job, "lookup_failed", err)
return
}
if itemID == "" {
// Nothing to remove. Ordinary rather than a failure: the catalogue may never have
// held it, or a previous delivery of this event already did the work.
i.settle(ctx, job, store.IngestDone, "absent", "", nil)
i.log().Info("arr ingest",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", "absent")
return
}
removed, err := i.Store.DeleteLibraryItem(ctx, itemID)
if err != nil {
i.defer_(ctx, job, "delete_failed", err)
return
}
i.settle(ctx, job, store.IngestDone, "removed", itemID, nil)
i.log().Info("arr ingest",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", "removed", "rows", removed, "item", itemID)
}
// resolve turns what the *arr said into Emby items, narrowly.
//
// The second return is the item the work was about, for the log and the console. It is
// empty for a series-wide refresh, which is about a show rather than about one file.
func (i *Ingester) resolve(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) ([]json.RawMessage, string, error) {
switch request.Kind {
case KindMovie:
return i.resolveMovie(ctx, request, cred)
case KindEpisode, KindSeries:
return i.resolveFromSeries(ctx, request, cred)
}
return nil, "", fmt.Errorf("library: unknown ingest kind %q", request.Kind)
}
func (i *Ingester) resolveMovie(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) ([]json.RawMessage, string, error) {
page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{
"SearchTerm": {request.Title},
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"Limit": {"20"},
}))
if err != nil {
return nil, "", err
}
if page == nil {
return nil, "", nil
}
match, id := pickByTitle(page.Items, request.Title, request.Year)
if match == nil {
return nil, "", nil
}
return []json.RawMessage{match}, id, nil
}
// resolveFromSeries handles both an episode and a whole-series refresh, because they share
// the expensive half: working out which Emby show this is.
func (i *Ingester) resolveFromSeries(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) ([]json.RawMessage, string, error) {
seriesID, seriesPayload, err := i.seriesItem(ctx, request, cred)
if err != nil {
return nil, "", err
}
if seriesID == "" {
return nil, "", nil
}
params := itemQuery(url.Values{})
if request.Kind == KindEpisode && request.Season > 0 {
// One season rather than a show. A long-running series is a thousand records and
// this runs per imported file.
params.Set("Season", strconv.Itoa(request.Season))
}
page, err := i.Emby.Episodes(ctx, cred, seriesID, params)
if err != nil {
return nil, "", err
}
out := make([]json.RawMessage, 0, 8)
if seriesPayload != nil {
// A show Emby has only just created has no row here yet, and its episodes would be
// imported as children of a series the catalogue has never heard of.
out = append(out, seriesPayload)
}
if page == nil {
return out, "", nil
}
if request.Kind == KindSeries {
// A rename moved files; which files is not something Sonarr says, so the show is
// the unit of work and one re-read settles all of them.
return append(out, page.Items...), seriesID, nil
}
for _, raw := range page.Items {
var parsed struct {
ID string `json:"Id"`
IndexNumber *int `json:"IndexNumber"`
ParentIndexNumber *int `json:"ParentIndexNumber"`
}
if json.Unmarshal(raw, &parsed) != nil || parsed.IndexNumber == nil {
continue
}
if *parsed.IndexNumber != request.Episode {
continue
}
if parsed.ParentIndexNumber != nil && *parsed.ParentIndexNumber != request.Season {
continue
}
return append(out, raw), parsed.ID, nil
}
// The series is there and the episode is not: Emby has the show but has not scanned the
// new file. Reporting nothing found keeps that on the deferral path — but the series
// payload is still worth writing if it was new.
if len(out) > 0 {
if _, err := i.Store.UpsertLibraryItems(ctx, seriesItems(out), time.Now().UTC()); err != nil {
i.log().Debug("could not write the series row ahead of its episode", "error", err)
}
}
return nil, "", nil
}
// seriesItem answers which Emby series this is, preferring the catalogue.
//
// The local index is one query the gateway already makes elsewhere and it is right for
// every show that has ever been imported. Emby is asked only when it misses, which is
// exactly the case this feature exists for — a brand-new show whose first episode has just
// landed — and the payload comes back with it so the series row can be written too.
func (i *Ingester) seriesItem(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) (string, json.RawMessage, error) {
if id := i.localSeriesID(ctx, request.Series, request.SeriesYear); id != "" {
return id, nil, nil
}
page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{
"SearchTerm": {request.Series},
"IncludeItemTypes": {"Series"},
"Recursive": {"true"},
"Limit": {"20"},
}))
if err != nil {
return "", nil, err
}
if page == nil {
return "", nil, nil
}
match, id := pickByTitle(page.Items, request.Series, request.SeriesYear)
return id, match, nil
}
func (i *Ingester) localSeriesID(ctx context.Context, title string, year int) string {
refs, err := i.Store.SeriesRefs(ctx)
if err != nil {
i.log().Debug("series index unavailable for ingest", "error", err)
return ""
}
return matchByTitle(refs, title, year)
}
// localItemID resolves a delete against the catalogue.
func (i *Ingester) localItemID(ctx context.Context, request IngestRequest) (string, error) {
switch request.Kind {
case KindMovie:
named, err := i.Store.LibraryItemsByName(ctx, "Movie", request.Title)
if err != nil {
return "", err
}
return matchNamed(named, request.Title, request.Year), nil
case KindSeries:
return i.localSeriesID(ctx, request.Series, request.SeriesYear), nil
case KindEpisode:
seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear)
if seriesID == "" {
return "", nil
}
episodes, err := i.Store.CreditsSeriesEpisodes(ctx, []string{seriesID})
if err != nil {
return "", err
}
for _, episode := range episodes {
if episode.Episode == request.Episode && episode.Season == request.Season {
return episode.ItemID, nil
}
}
}
return "", nil
}
// nudge asks Emby to look at the folder the file landed in.
//
// Best-effort and deliberately unreported: it is the same trick the subtitle download uses
// after Bazarr writes a sidecar, and a household whose Emby scans on its own does not need
// it. Refusing to nudge without a parent is the important half — a refresh of nothing is a
// request that cannot help.
func (i *Ingester) nudge(ctx context.Context, request IngestRequest, cred emby.Credentials) {
if request.Kind == KindMovie {
return
}
seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear)
if seriesID == "" {
return
}
if err := i.Emby.RefreshItem(ctx, cred, seriesID); err != nil {
i.log().Debug("could not ask emby to rescan a series", "series", seriesID, "error", err)
}
}
// defer_ schedules another attempt, or gives up.
func (i *Ingester) defer_(ctx context.Context, job store.IngestJob, outcome string, cause error) {
attempts := job.Attempts + 1
if attempts >= maxAttempts {
i.settle(ctx, job, store.IngestFailed, outcome, "", cause)
i.log().Warn("arr ingest gave up",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", outcome, "attempts", attempts, "error", errorText(cause))
return
}
retryAt := time.Now().UTC().Add(IngestRetryDelay(attempts))
if err := i.Store.FinishIngest(
ctx, job.Key, store.IngestPending, outcome, "", errorText(cause), retryAt,
); err != nil {
i.log().Warn("could not reschedule ingest work", "key", job.Key, "error", err)
}
i.log().Debug("arr ingest deferred",
"event", "arr_ingest", "key", job.Key, "reason", job.Reason, "outcome", outcome,
"attempts", attempts, "retry_in", IngestRetryDelay(attempts).String(),
"error", errorText(cause))
}
func (i *Ingester) settle(
ctx context.Context, job store.IngestJob, state, outcome, itemID string, cause error,
) {
// Detached from the job's own budget: a row that timed out must still record that it
// did, or the next pass claims it again immediately and the backoff never applies.
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := i.Store.FinishIngest(
writeCtx, job.Key, state, outcome, itemID, errorText(cause), time.Now().UTC(),
); err != nil {
i.log().Warn("could not record ingest outcome", "key", job.Key, "error", err)
}
}
// IngestRetryDelay is the backoff, and it is a step function rather than an exponent so
// the schedule can be read off the page: a minute, five, twenty, an hour, then four-hourly
// out to the attempt limit. The early steps are short because the common cause is Emby not
// having scanned yet, which resolves in minutes; the late ones are long because by then the
// cause is something a faster retry cannot fix.
func IngestRetryDelay(attempts int) time.Duration {
switch {
case attempts <= 1:
return time.Minute
case attempts == 2:
return 5 * time.Minute
case attempts == 3:
return 20 * time.Minute
case attempts == 4:
return time.Hour
default:
return 4 * time.Hour
}
}
// itemQuery is the field set every lookup here uses, and it is deliberately the scheduled
// import's own.
//
// Thinning it would leave an event-imported title without People, MediaStreams or
// ProviderIds — so no cast on its page, no ratings lookup and no format badges — until Emby
// next reported it changed, which for a film nobody edits again is never. Syncer.Find makes
// the same promise for the same reason.
func itemQuery(params url.Values) url.Values {
params.Set("Fields", syncFields)
params.Set("ImageTypeLimit", "1")
params.Set("EnableImages", "true")
params.Set("EnableImageTypes", syncImageTypes)
params.Set("EnableTotalRecordCount", "false")
params.Set("EnableUserData", "false")
return params
}
// pickByTitle chooses the item a title and year names.
//
// Year-qualified first and title-only as the fallback, the rule the schedule row's series
// index already applies: an *arr and Emby disagree about a show's year far more often than
// they disagree about its name, but where both know the year it is what separates a remake
// from its original.
func pickByTitle(items []json.RawMessage, title string, year int) (json.RawMessage, string) {
want := NormalizedTitle(title)
if want == "" {
return nil, ""
}
var fallback json.RawMessage
var fallbackID string
for _, raw := range items {
var parsed struct {
ID string `json:"Id"`
Name string `json:"Name"`
ProductionYear *int `json:"ProductionYear"`
}
if json.Unmarshal(raw, &parsed) != nil || parsed.ID == "" {
continue
}
if NormalizedTitle(parsed.Name) != want {
continue
}
if year > 0 && parsed.ProductionYear != nil && *parsed.ProductionYear == year {
return raw, parsed.ID
}
if fallback == nil {
fallback, fallbackID = raw, parsed.ID
}
}
return fallback, fallbackID
}
func matchByTitle(refs []store.SeriesRef, title string, year int) string {
want := NormalizedTitle(title)
if want == "" {
return ""
}
fallback := ""
for _, ref := range refs {
if NormalizedTitle(ref.Name) != want {
continue
}
if year > 0 && ref.Year == year {
return ref.ID
}
if fallback == "" {
fallback = ref.ID
}
}
return fallback
}
func matchNamed(items []store.NamedItem, title string, year int) string {
want := NormalizedTitle(title)
if want == "" {
return ""
}
fallback := ""
for _, item := range items {
if NormalizedTitle(item.Name) != want {
continue
}
if year > 0 && item.Year == year {
return item.ID
}
if fallback == "" {
fallback = item.ID
}
}
return fallback
}
// seriesItems is the series payload on its own, for the case where the episode has not
// appeared yet but the show has.
func seriesItems(payloads []json.RawMessage) []store.LibraryItem {
out := make([]store.LibraryItem, 0, len(payloads))
for _, raw := range payloads {
if item, ok := toLibraryItem(raw); ok && item.Type == "Series" {
out = append(out, item)
}
}
return out
}
func errorText(err error) string {
if err == nil {
return ""
}
return strings.TrimSpace(err.Error())
}
func sleep(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// Enqueue records work a webhook implied, and answers how much of it was news.
//
// It is the hook's whole job. Everything expensive happens later, on the worker, which is
// what lets the hook answer Sonarr in a millisecond and — more importantly — what lets it
// answer at all during quiet hours, when the work itself must wait.
func (i *Ingester) Enqueue(
ctx context.Context, source string, requests []IngestRequest,
) (int, error) {
if i == nil || i.Store == nil || len(requests) == 0 {
return 0, nil
}
due := time.Now().UTC().Add(i.SettleDelay())
fresh := 0
var firstErr error
for _, request := range requests {
payload, err := json.Marshal(request)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
inserted, err := i.Store.EnqueueIngest(ctx, store.IngestJob{
Key: request.Key,
Action: request.Action,
Kind: request.Kind,
Reason: request.Reason,
Source: source,
Payload: payload,
DueAt: due,
})
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if inserted {
fresh++
continue
}
// A repeat delivery is ordinary — both *arrs re-notify on retry — so it is DEBUG,
// the same stance the per-keystroke search line takes.
i.log().Debug("arr ingest already queued",
"event", "arr_ingest", "key", request.Key, "source", source, "reason", request.Reason)
}
return fresh, firstErr
}