Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Pick the configuration that matches your Plex server:

| Mount | Description |
| --- | --- |
| `/config` | Persistent cache (`profiles.json` learned language profiles, `tokens.json` encrypted shared-user tokens, `state.json` sync state). Mount a named volume or host path to preserve data across restarts. A corrupt file resets only its own section; a `cache.json` from an earlier version migrates automatically on first start. |
| `/config` | Persistent cache (`profiles.json` learned language profiles, `tokens.json` encrypted shared-user tokens, `state.json` sync state, `.plex-language-sync-last-run` completion record for the deep-scan safety net). Mount a named volume or host path to preserve data across restarts. A corrupt file resets only its own section; a `cache.json` from an earlier version migrates automatically on first start. |

## Graceful shutdown

Expand Down
20 changes: 6 additions & 14 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// Package cache is the on-disk persistence layer for processed-episode
// deduplication, per-user language profiles, shared-user tokens, and the
// scheduler's last-run marker.
// deduplication, per-user language profiles, and shared-user tokens.
//
// On-disk layout: state is split across three files in the cache dir by
// retention class, so one corrupt file never costs another class's state:
//
// profiles.json language_profiles irreplaceable learned state
// tokens.json user_tokens re-fetchable encrypted secrets
// state.json processed_episodes, last_scheduler_run disposable operational state
// profiles.json language_profiles irreplaceable learned state
// tokens.json user_tokens re-fetchable encrypted secrets
// state.json processed_episodes disposable operational state
//
// Field names, types, and JSON tags within each file are an inviolate
// read-forward / write-back contract across deploys — any change is a
Expand Down Expand Up @@ -55,8 +54,8 @@ const (
)

// Data is the in-memory state shape. It doubles as the decode target for
// the legacy pre-split cache.json union schema, whose field names and JSON
// tags it preserves verbatim (read-forward contract).
// the legacy pre-split cache.json union schema; a legacy key with no field
// here (last_scheduler_run) is ignored on decode.
type Data struct {
// ProcessedEpisodes tracks recently processed episode keys to avoid
// re-processing the same episode on rapid successive events.
Expand All @@ -77,9 +76,6 @@ type Data struct {
// UserTokens maps userID → accessToken for shared users. Persisted in
// tokens.json, encrypted at the disk boundary when a key is set.
UserTokens map[string]string `json:"user_tokens"`
// LastSchedulerRun is the unix timestamp of the last scheduler run.
// Persisted in state.json.
LastSchedulerRun int64 `json:"last_scheduler_run"`
}

// profilesData is the profiles.json schema (irreplaceable learned state).
Expand All @@ -96,7 +92,6 @@ type tokensData struct {
// stateData is the state.json schema (disposable operational state).
type stateData struct {
ProcessedEpisodes map[string]int64 `json:"processed_episodes"`
LastSchedulerRun int64 `json:"last_scheduler_run"`
}

// Cache is the concurrent-safe persistent cache. The zero value is usable;
Expand Down Expand Up @@ -242,7 +237,6 @@ func (c *Cache) applyLegacyLocked(legacy *Data) {
c.data.UserTokens = legacy.UserTokens
c.decryptTokensLocked()
}
c.data.LastSchedulerRun = legacy.LastSchedulerRun
}

// overlayProfilesLocked loads profiles.json over the baseline. Returns 1
Expand Down Expand Up @@ -319,7 +313,6 @@ func (c *Cache) overlayStateLocked(dir string, errs *[]error) int {
if c.data.ProcessedEpisodes == nil {
c.data.ProcessedEpisodes = make(map[string]int64)
}
c.data.LastSchedulerRun = sd.LastSchedulerRun
return 1
}

Expand Down Expand Up @@ -475,7 +468,6 @@ func (c *Cache) encodeAllForSave() (profiles, tokens, state []byte, err error) {
}
if state, err = json.MarshalIndent(&stateData{
ProcessedEpisodes: c.data.ProcessedEpisodes,
LastSchedulerRun: c.data.LastSchedulerRun,
}, "", " "); err != nil {
return nil, nil, nil, fmt.Errorf("marshal %s: %w", stateFile, err)
}
Expand Down
49 changes: 25 additions & 24 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ func TestCacheSaveLoadRoundTrip(t *testing.T) {
"1": {"jpn": "eng", "eng": ""},
}
orig.data.UserTokens = map[string]string{"2": "t2"}
orig.data.LastSchedulerRun = 1700000000

if err := orig.Save(dir); err != nil {
t.Fatalf("Save() error = %v", err)
Expand All @@ -83,8 +82,27 @@ func TestCacheSaveLoadRoundTrip(t *testing.T) {
if loaded.data.UserTokens["2"] != "t2" {
t.Errorf("UserTokens[2] = %q, want t2", loaded.data.UserTokens["2"])
}
if loaded.data.LastSchedulerRun != 1700000000 {
t.Errorf("LastSchedulerRun = %d, want 1700000000", loaded.data.LastSchedulerRun)
}

// TestCacheLoadIgnoresRetiredMarkerKey pins the read-forward contract for
// files written by earlier versions: state.json (and the legacy cache.json)
// used to carry a last_scheduler_run key, and a file still holding it must
// load its live sections cleanly. Fails if the load path ever opts into
// strict decoding (DisallowUnknownFields).
func TestCacheLoadIgnoresRetiredMarkerKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
state := `{"processed_episodes":{"timeline:9":1700000000},"last_scheduler_run":1700000000}`
if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(state), 0o600); err != nil {
t.Fatal(err)
}

c := New()
if err := c.Load(dir); err != nil {
t.Errorf("Load() with a retired key in state.json = %v, want nil", err)
}
if len(c.data.ProcessedEpisodes) != 1 {
t.Errorf("ProcessedEpisodes = %v, want the seeded entry loaded beside the retired key", c.data.ProcessedEpisodes)
}
}

Expand Down Expand Up @@ -211,7 +229,6 @@ func TestCacheLoadCorruptSectionIsolation(t *testing.T) {
orig.data.ProcessedEpisodes = map[string]int64{"streams:1:100:1:2": time.Now().Unix()}
orig.data.LanguageProfiles = map[string]map[string]string{"1": {"jpn": "eng"}}
orig.data.UserTokens = map[string]string{"2": "t2"}
orig.data.LastSchedulerRun = 1700000000
if err := orig.Save(dir); err != nil {
t.Fatalf("seed Save() error = %v", err)
}
Expand Down Expand Up @@ -242,8 +259,7 @@ func TestCacheLoadCorruptSectionIsolation(t *testing.T) {

gotProfiles := loaded.data.LanguageProfiles["1"]["jpn"] == "eng"
gotTokens := loaded.data.UserTokens["2"] == "t2"
gotState := loaded.data.LastSchedulerRun == 1700000000 &&
len(loaded.data.ProcessedEpisodes) == 1
gotState := len(loaded.data.ProcessedEpisodes) == 1

if want := tc.corrupt != profilesFile; gotProfiles != want {
t.Errorf("profiles survived = %v, want %v", gotProfiles, want)
Expand Down Expand Up @@ -283,7 +299,6 @@ func TestCacheLoadMigratesLegacyCacheJSON(t *testing.T) {
ProcessedEpisodes: map[string]int64{"timeline:9": time.Now().Unix()},
LanguageProfiles: map[string]map[string]string{"1": {"jpn": "eng"}},
UserTokens: map[string]string{"2": "plain-tok"},
LastSchedulerRun: 1700000000,
})

key, err := DeriveKey("admin-token")
Expand All @@ -297,7 +312,7 @@ func TestCacheLoadMigratesLegacyCacheJSON(t *testing.T) {
}

if c.data.LanguageProfiles["1"]["jpn"] != "eng" || c.data.UserTokens["2"] != "plain-tok" ||
c.data.LastSchedulerRun != 1700000000 || len(c.data.ProcessedEpisodes) != 1 {
len(c.data.ProcessedEpisodes) != 1 {
t.Errorf("migrated state incomplete: %+v", c.data)
}
for _, name := range splitFiles {
Expand Down Expand Up @@ -343,14 +358,12 @@ func TestCacheLoadSplitWinsOverStaleLegacy(t *testing.T) {
split := New()
split.data.LanguageProfiles = map[string]map[string]string{"1": {"jpn": "eng"}}
split.data.UserTokens = map[string]string{"2": "new-tok"}
split.data.LastSchedulerRun = 2000000000
if err := split.Save(dir); err != nil {
t.Fatalf("Save() error = %v", err)
}
legacyWrite(t, dir, Data{
LanguageProfiles: map[string]map[string]string{"1": {"jpn": "STALE"}},
UserTokens: map[string]string{"2": "STALE"},
LastSchedulerRun: 1,
})

loaded := New()
Expand All @@ -365,9 +378,6 @@ func TestCacheLoadSplitWinsOverStaleLegacy(t *testing.T) {
t.Errorf("tokens = %q, want the split file's value to win over stale legacy",
loaded.data.UserTokens["2"])
}
if loaded.data.LastSchedulerRun != 2000000000 {
t.Errorf("LastSchedulerRun = %d, want the split file's value", loaded.data.LastSchedulerRun)
}
if _, err := os.Stat(filepath.Join(dir, legacyCacheFile)); !os.IsNotExist(err) {
t.Errorf("stale legacy cache.json not removed (stat err = %v)", err)
}
Expand All @@ -383,7 +393,6 @@ func TestCacheLoadLegacyFillsMissingSection(t *testing.T) {
legacyWrite(t, dir, Data{
LanguageProfiles: map[string]map[string]string{"1": {"jpn": "LEGACY"}},
UserTokens: map[string]string{"2": "legacy-tok"},
LastSchedulerRun: 1700000000,
})
pd, err := json.Marshal(&profilesData{
LanguageProfiles: map[string]map[string]string{"1": {"jpn": "SPLIT"}},
Expand All @@ -405,9 +414,6 @@ func TestCacheLoadLegacyFillsMissingSection(t *testing.T) {
if loaded.data.UserTokens["2"] != "legacy-tok" {
t.Errorf("tokens = %q, want the legacy value for the missing section", loaded.data.UserTokens["2"])
}
if loaded.data.LastSchedulerRun != 1700000000 {
t.Errorf("LastSchedulerRun = %d, want the legacy value", loaded.data.LastSchedulerRun)
}
if _, err := os.Stat(filepath.Join(dir, legacyCacheFile)); !os.IsNotExist(err) {
t.Errorf("legacy cache.json not removed after completing migration (stat err = %v)", err)
}
Expand Down Expand Up @@ -535,7 +541,7 @@ func TestCacheLoadLeavesAuthoritativeSplitFilesUntouched(t *testing.T) {
LanguageProfiles: map[string]map[string]string{"1": {"jpn": "eng"}},
}),
tokensFile: mustCompactJSON(t, &tokensData{UserTokens: map[string]string{"2": "new-tok"}}),
stateFile: mustCompactJSON(t, &stateData{LastSchedulerRun: 2000000000}),
stateFile: mustCompactJSON(t, &stateData{ProcessedEpisodes: map[string]int64{"timeline:9": 2000000000}}),
}
for name, raw := range written {
if err := os.WriteFile(filepath.Join(dir, name), raw, 0o600); err != nil {
Expand Down Expand Up @@ -581,7 +587,7 @@ func mustCompactJSON(t *testing.T, v any) []byte {
return raw
}

// --- PBT: JSON round-trip preserves LastSchedulerRun + map lengths ---
// --- PBT: JSON round-trip preserves the map lengths ---

func TestCacheDataJSONRoundTrip(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
Expand All @@ -596,7 +602,6 @@ func TestCacheDataJSONRoundTrip(t *testing.T) {
ProcessedEpisodes: processed,
LanguageProfiles: make(map[string]map[string]string),
UserTokens: make(map[string]string),
LastSchedulerRun: int64(rapid.IntRange(0, 2000000000).Draw(t, "last_run")),
}

data, err := json.Marshal(&original)
Expand All @@ -613,10 +618,6 @@ func TestCacheDataJSONRoundTrip(t *testing.T) {
t.Errorf("ProcessedEpisodes length: got %d, want %d",
len(decoded.ProcessedEpisodes), len(original.ProcessedEpisodes))
}
if decoded.LastSchedulerRun != original.LastSchedulerRun {
t.Errorf("LastSchedulerRun: got %d, want %d",
decoded.LastSchedulerRun, original.LastSchedulerRun)
}
})
}

Expand Down
30 changes: 0 additions & 30 deletions internal/cache/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cache
import (
"sync"
"testing"
"time"

"github.com/cplieger/plex-language-sync/internal/streams"
)
Expand All @@ -24,8 +23,6 @@ type Contract interface {
IntentFor(userID, showKey string) (streams.Intent, bool)
UserTokens() map[string]string
SetUserTokens(tokens map[string]string)
LastSchedulerRun() time.Time
SetLastSchedulerRun(t time.Time)
}

// RunContract exercises the persisted-cache contract against any
Expand Down Expand Up @@ -80,36 +77,9 @@ func RunContract(t *testing.T, c Contract) {
wg.Wait()
})

schedulerRunContract(t, c)
checkAndMarkContract(t, c)
}

// schedulerRunContract exercises the scheduler-run portion of the Contract
// contract: the last-scheduler-run marker round-trips a whole-second value and
// resets to the zero time. Split out of RunCacheContract to keep that
// function's cognitive complexity under the gate.
func schedulerRunContract(t *testing.T, c Contract) {
t.Helper()

t.Run("scheduler_run_roundtrip", func(t *testing.T) {
// Whole-second value: internal/cache persists the marker as a unix
// int64 (time.Unix truncation), so the shared contract is pinned at
// second granularity that both implementations honour.
want := time.Unix(1700000000, 0)
c.SetLastSchedulerRun(want)
if got := c.LastSchedulerRun(); !got.Equal(want) {
t.Errorf("LastSchedulerRun = %v, want %v", got, want)
}
})

t.Run("scheduler_run_zero", func(t *testing.T) {
c.SetLastSchedulerRun(time.Time{})
if got := c.LastSchedulerRun(); !got.IsZero() {
t.Errorf("LastSchedulerRun after zero set = %v, want zero", got)
}
})
}

// Language-code literals shared by the contract subtests.
const (
langJPN = "jpn"
Expand Down
28 changes: 0 additions & 28 deletions internal/cache/scheduler.go

This file was deleted.

34 changes: 0 additions & 34 deletions internal/cache/scheduler_test.go

This file was deleted.

Loading
Loading