App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+63 -15
View File
@@ -716,27 +716,79 @@ func rowKey(value string) string {
return b.String()
}
// ImportIfDue runs an import only when the persisted state says one is owed. Due-ness is
// measured from the last recorded import rather than from process uptime, so a restart —
// a redeploy, a crash loop, a container the healthcheck bounced — cannot buy an extra
// pass, and a gateway that is restarted more often than fullEvery still reconciles.
func (s *Service) ImportIfDue(
ctx context.Context,
importEvery, fullEvery time.Duration,
) (ImportResult, bool, error) {
state, err := s.store.TracearrImportState(ctx)
if err != nil {
return ImportResult{}, false, err
}
full, due := importDue(state, time.Now().UTC(), importEvery, fullEvery)
if !due {
s.log.Debug("Tracearr import not due",
"lastIncremental", state.LastIncrementalAt, "lastFull", state.LastFullAt)
return ImportResult{}, false, nil
}
result, err := s.Import(ctx, full)
return result, err == nil, err
}
// importDue is the whole scheduling rule, kept pure so it can be tested without a
// database. A stamp in the future is treated as due: a clock correction must not be able
// to strand the importer for an arbitrary length of time.
func importDue(
state store.TracearrImportState,
now time.Time,
importEvery, fullEvery time.Duration,
) (full bool, due bool) {
elapsed := func(at *time.Time) (time.Duration, bool) {
if at == nil {
return 0, false
}
return now.Sub(*at), true
}
if fullEvery > 0 {
since, recorded := elapsed(state.LastFullAt)
if !recorded || since >= fullEvery || since < 0 {
return true, true
}
}
if importEvery > 0 {
since, recorded := elapsed(state.LastIncrementalAt)
if !recorded || since >= importEvery || since < 0 {
return false, true
}
}
return false, false
}
func (s *Service) Schedule(
ctx context.Context,
importEvery, fullEvery time.Duration,
rebuildHour int,
) {
// One ticker asks "is anything owed?"; the persisted stamps decide what and whether.
// Two independent tickers measured process uptime, which is what let a restart reset
// the cadence and a bounced container import far more often than configured.
checkEvery := importEvery
if checkEvery <= 0 || (fullEvery > 0 && fullEvery < checkEvery) {
checkEvery = fullEvery
}
var importC <-chan time.Time
var importTicker *time.Ticker
if importEvery > 0 {
importTicker = time.NewTicker(importEvery)
if checkEvery > 0 {
importTicker := time.NewTicker(checkEvery)
importC = importTicker.C
defer importTicker.Stop()
s.log.Info("Tracearr auto-import scheduled",
"incremental", importEvery.String(), "full", fullEvery.String())
} else {
s.log.Info("Tracearr auto-import disabled")
}
var fullC <-chan time.Time
var fullTicker *time.Ticker
if fullEvery > 0 {
fullTicker = time.NewTicker(fullEvery)
fullC = fullTicker.C
defer fullTicker.Stop()
}
nextRebuild := nextDailyRebuild(time.Now(), s.location, rebuildHour)
rebuildTimer := time.NewTimer(time.Until(nextRebuild))
defer rebuildTimer.Stop()
@@ -746,13 +798,9 @@ func (s *Service) Schedule(
case <-ctx.Done():
return
case <-importC:
if _, err := s.Import(ctx, false); err != nil {
if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err)
}
case <-fullC:
if _, err := s.Import(ctx, true); err != nil {
s.log.Warn("scheduled full Tracearr import failed", "error", err)
}
case <-rebuildTimer.C:
if err := s.RebuildAll(ctx, true); err != nil {
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
+81
View File
@@ -99,6 +99,87 @@ func TestTracearrSessionTerminalRequiresCompletedState(t *testing.T) {
}
}
func TestImportDueMeasuresElapsedTimeRatherThanProcessUptime(t *testing.T) {
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
at := func(d time.Duration) *time.Time {
value := now.Add(-d)
return &value
}
const incremental = 5 * time.Minute
const full = 24 * time.Hour
for _, testCase := range []struct {
name string
state store.TracearrImportState
wantFull bool
wantDue bool
wantSkip bool
}{
{
name: "never imported runs a full pass",
state: store.TracearrImportState{}, wantFull: true, wantDue: true,
},
{
// The restart case: without persisted stamps every boot re-imported.
name: "recent import is not repeated after a restart",
state: store.TracearrImportState{
LastIncrementalAt: at(30 * time.Second), LastFullAt: at(2 * time.Hour),
},
wantSkip: true,
},
{
name: "elapsed incremental interval is due",
state: store.TracearrImportState{
LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(2 * time.Hour),
},
wantDue: true,
},
{
// A gateway restarted daily never reconciled: the full ticker restarted too.
name: "elapsed full interval wins over the incremental one",
state: store.TracearrImportState{
LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(25 * time.Hour),
},
wantFull: true, wantDue: true,
},
{
name: "a stamp in the future does not strand the importer",
state: store.TracearrImportState{
LastIncrementalAt: at(-time.Hour), LastFullAt: at(-time.Hour),
},
wantFull: true, wantDue: true,
},
} {
t.Run(testCase.name, func(t *testing.T) {
gotFull, gotDue := importDue(testCase.state, now, incremental, full)
if testCase.wantSkip {
if gotDue {
t.Fatal("import ran when none was owed")
}
return
}
if gotDue != testCase.wantDue || gotFull != testCase.wantFull {
t.Fatalf("full=%v due=%v, want full=%v due=%v",
gotFull, gotDue, testCase.wantFull, testCase.wantDue)
}
})
}
}
func TestImportDueRespectsDisabledIntervals(t *testing.T) {
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
if _, due := importDue(store.TracearrImportState{}, now, 0, 0); due {
t.Fatal("import was due with scheduling turned off")
}
stale := now.Add(-48 * time.Hour)
state := store.TracearrImportState{LastIncrementalAt: &stale, LastFullAt: &stale}
full, due := importDue(state, now, 5*time.Minute, 0)
if !due || full {
t.Fatalf("full=%v due=%v, want an incremental pass with full reconciliation off",
full, due)
}
}
func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)