Trakt lists: keep the Radarr/Sonarr lookups of their titles on disk

Library membership is asked every time via movie?tmdbId= / series?tvdbId=;
only the lookups of titles not in the library are stored (3 days).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 20:48:20 +02:00
co-authored by Claude Opus 5.5
parent a70daaa0a3
commit f0bc284e82
10 changed files with 281 additions and 15 deletions
+6 -3
View File
@@ -37,7 +37,8 @@ type App struct {
theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists, casts
people *peopleCache // casts loaded from Trakt
disk *diskCache // posters and casts kept between runs
disk *diskCache // posters, casts and lookups kept between runs
lookups *lookupStore // Radarr/Sonarr lookups of Trakt list titles
// Called (on the UI goroutine) a moment after a release was grabbed.
afterGrab []func()
// The Movies and Series views, to refresh their Trakt lists.
@@ -188,8 +189,9 @@ func New(cfg *config.Config) *App {
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots)
}
movies := newSearchView(a, radarrBackend{radarrClient})
series := newSearchView(a, sonarrBackend{sonarrClient})
a.lookups = newLookupStore(a.disk)
movies := newSearchView(a, radarrBackend{radarrClient, a.lookups})
series := newSearchView(a, sonarrBackend{sonarrClient, a.lookups})
a.searches = []*searchView{movies, series}
a.views = []view{
{key: '1', name: "Movies", body: movies.root, onShow: movies.activate, hints: searchHints},
@@ -463,6 +465,7 @@ func (a *App) Run() error {
if a.people.dirty { // casts fetched in the last seconds
_ = a.disk.savePeople(a.peopleSnapshot())
}
_ = a.lookups.save()
}()
go a.disk.prunePosters()
// The screen draws through the theme (see themedScreen).
+48 -10
View File
@@ -72,7 +72,10 @@ func (m movieResult) withID(id int) result {
return m
}
type radarrBackend struct{ c *radarr.Client }
type radarrBackend struct {
c *radarr.Client
lookups *lookupStore
}
func (b radarrBackend) name() string { return "Radarr" }
func (b radarrBackend) noun() string { return "Movies" }
@@ -91,14 +94,30 @@ func (b radarrBackend) search(ctx context.Context, term string) ([]result, error
func (radarrBackend) lists() []trakt.List { return trakt.MovieLists }
func (b radarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
if e.IDs.TMDB == 0 {
id := e.IDs.TMDB
if id == 0 {
return nil, errNotListed
}
found, err := b.search(ctx, radarr.TMDBTerm(e.IDs.TMDB))
if err != nil || len(found) == 0 {
if m, ok, err := b.c.MovieByTMDB(ctx, id); err != nil {
return nil, err
} else if ok {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := radarr.TMDBTerm(id)
var m radarr.Movie
if b.lookups.get(key, &m) {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, false)}, nil
}
movies, err := b.c.Lookup(ctx, key)
if err != nil || len(movies) == 0 {
return nil, err
}
return found[0], nil
m = movies[0]
if !m.InLibrary() {
b.lookups.put(key, m)
}
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, m.InLibrary())}, nil
}
func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
@@ -136,7 +155,10 @@ func (s seriesResult) withID(id int) result {
return s
}
type sonarrBackend struct{ c *sonarr.Client }
type sonarrBackend struct {
c *sonarr.Client
lookups *lookupStore
}
func (b sonarrBackend) name() string { return "Sonarr" }
func (b sonarrBackend) noun() string { return "Series" }
@@ -165,14 +187,30 @@ func (b sonarrBackend) search(ctx context.Context, term string) ([]result, error
func (sonarrBackend) lists() []trakt.List { return trakt.ShowLists }
func (b sonarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
if e.IDs.TVDB == 0 {
id := e.IDs.TVDB
if id == 0 {
return nil, errNotListed
}
found, err := b.search(ctx, "tvdb:"+itoa(e.IDs.TVDB))
if err != nil || len(found) == 0 {
if s, ok, err := b.c.SeriesByTVDB(ctx, id); err != nil {
return nil, err
} else if ok {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := "tvdb:" + itoa(id)
var s sonarr.Series
if b.lookups.get(key, &s) {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, false)}, nil
}
series, err := b.c.Lookup(ctx, key)
if err != nil || len(series) == 0 {
return nil, err
}
return found[0], nil
s = series[0]
if !s.InLibrary() {
b.lookups.put(key, s)
}
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, s.InLibrary())}, nil
}
func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
+36
View File
@@ -169,6 +169,42 @@ func (d *diskCache) savePeople(all map[string]savedPeople) error {
return writeAtomic(d.peoplePath(), data)
}
func (d *diskCache) lookupsPath() string { return filepath.Join(d.dir, "lookups.json") }
// loadLookups reads the stored Radarr/Sonarr lookups that are not too old.
func (d *diskCache) loadLookups() map[string]savedLookup {
out := map[string]savedLookup{}
if d.dir == "" {
return out
}
data, err := os.ReadFile(d.lookupsPath())
if err != nil {
return out
}
var all map[string]savedLookup
if json.Unmarshal(data, &all) != nil {
return out // unreadable: start afresh, it is only a cache
}
for k, l := range all {
if time.Since(l.Saved) <= lookupTTL {
out[k] = l
}
}
return out
}
// saveLookups writes the lookups.
func (d *diskCache) saveLookups(all map[string]savedLookup) error {
if d.dir == "" {
return nil
}
data, err := json.Marshal(all)
if err != nil {
return err
}
return writeAtomic(d.lookupsPath(), data)
}
// writeAtomic writes a file through a temporary file and a rename, creating
// the directory (private) if needed.
func writeAtomic(path string, data []byte) error {
+95
View File
@@ -0,0 +1,95 @@
package ui
import (
"encoding/json"
"maps"
"slices"
"sync"
"time"
)
const (
lookupTTL = 3 * 24 * time.Hour // a stored lookup is used this long
maxLookupsSaved = 2000 // lookups kept (in memory and on disk)
lookupSaveDelay = 5 * time.Second // new lookups are written to disk this long after the last one
)
// lookupStore keeps the Radarr/Sonarr lookups of Trakt list titles that are
// not in the library, so a list shows at once after a restart: a lookup asks
// the metadata provider and takes about a second, while whether a title is
// in the library is asked of the server every time (that is quick, and must
// not be stale). Safe for use from any goroutine; read from disk on first use.
type lookupStore struct {
disk *diskCache
mu sync.Mutex
loaded bool
entries map[string]savedLookup
dirty bool
timer *time.Timer
}
// savedLookup is one lookup result as Radarr/Sonarr sent it.
type savedLookup struct {
Saved time.Time `json:"saved"`
Data json.RawMessage `json:"data"`
}
func newLookupStore(disk *diskCache) *lookupStore { return &lookupStore{disk: disk} }
func (s *lookupStore) load() {
if !s.loaded {
s.loaded, s.entries = true, s.disk.loadLookups()
}
}
// get decodes the stored lookup of key into v, if there is a fresh one.
func (s *lookupStore) get(key string, v any) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.load()
e, ok := s.entries[key]
if !ok || time.Since(e.Saved) > lookupTTL {
return false
}
return json.Unmarshal(e.Data, v) == nil
}
// put stores a lookup, dropping the oldest ones beyond maxLookupsSaved, and
// has it written to disk soon.
func (s *lookupStore) put(key string, v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.load()
s.entries[key] = savedLookup{Saved: time.Now(), Data: data}
if len(s.entries) > maxLookupsSaved {
keys := slices.SortedFunc(maps.Keys(s.entries), func(x, y string) int {
return s.entries[x].Saved.Compare(s.entries[y].Saved)
})
for _, k := range keys[:len(keys)-maxLookupsSaved] {
delete(s.entries, k)
}
}
s.dirty = true
if s.timer != nil {
s.timer.Stop()
}
s.timer = time.AfterFunc(lookupSaveDelay, func() { _ = s.save() })
}
// save writes the lookups to disk if there are new ones.
func (s *lookupStore) save() error {
s.mu.Lock()
if !s.dirty {
s.mu.Unlock()
return nil
}
s.dirty = false
all := maps.Clone(s.entries)
s.mu.Unlock()
return s.disk.saveLookups(all)
}
+63
View File
@@ -0,0 +1,63 @@
package ui
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func TestListLookupsKeptOnDisk(t *testing.T) {
var lookups, libraryAsks atomic.Int32
inLibrary := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie":
libraryAsks.Add(1)
if inLibrary && r.URL.Query().Get("tmdbId") == "949" {
w.Write([]byte(`[{"id":7,"title":"Heat","year":1995,"tmdbId":949}]`))
return
}
w.Write([]byte(`[{"id":3,"title":"Other","tmdbId":1}]`)) // ignores the filter
case "/api/v3/movie/lookup":
lookups.Add(1)
w.Write([]byte(`[{"title":"Heat","year":1995,"tmdbId":949,"overview":"Cops and robbers."}]`))
}
}))
defer srv.Close()
disk := &diskCache{dir: t.TempDir()}
entry := trakt.Entry{Title: "Heat", IDs: trakt.IDs{TMDB: 949}}
resolve := func() result {
t.Helper()
store := newLookupStore(disk) // a new run
b := radarrBackend{radarr.NewClient(srv.URL, "k"), store}
r, err := b.resolve(context.Background(), entry)
if err != nil || r == nil {
t.Fatalf("resolve: %v %v", r, err)
}
if err := store.save(); err != nil {
t.Fatal(err)
}
return r
}
if r := resolve(); r.inLibrary() || lookups.Load() != 1 {
t.Fatalf("first run: in library %v, %d lookups", r.inLibrary(), lookups.Load())
}
r := resolve()
if lookups.Load() != 1 || r.details().plot != "Cops and robbers." {
t.Errorf("second run: %d lookups, plot %q", lookups.Load(), r.details().plot)
}
// Added meanwhile (elsewhere): the library answer wins over the stored lookup.
inLibrary = true
if r := resolve(); !r.inLibrary() || lookups.Load() != 1 {
t.Errorf("after adding: in library %v, %d lookups", r.inLibrary(), lookups.Load())
}
if libraryAsks.Load() != 3 {
t.Errorf("library asked %d times", libraryAsks.Load())
}
}