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:
@@ -102,6 +102,19 @@ func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
|
||||
return movies, err
|
||||
}
|
||||
|
||||
// MovieByTMDB returns the library movie with the TMDb id, ok=false if the
|
||||
// library has none. Unlike a lookup it does not ask the metadata provider.
|
||||
func (c *Client) MovieByTMDB(ctx context.Context, tmdbID int) (Movie, bool, error) {
|
||||
var movies []Movie
|
||||
err := c.Do(ctx, http.MethodGet, "movie", url.Values{"tmdbId": {strconv.Itoa(tmdbID)}}, nil, &movies)
|
||||
for _, m := range movies { // checked: a version that ignores the filter sends the whole library
|
||||
if m.TMDBID == tmdbID && m.InLibrary() {
|
||||
return m, true, err
|
||||
}
|
||||
}
|
||||
return Movie{}, false, err
|
||||
}
|
||||
|
||||
// AddOptions are the choices from the add form.
|
||||
type AddOptions struct {
|
||||
QualityProfileID int
|
||||
|
||||
@@ -111,6 +111,19 @@ func (c *Client) Series(ctx context.Context) ([]Series, error) {
|
||||
return series, err
|
||||
}
|
||||
|
||||
// SeriesByTVDB returns the library series with the TVDB id, ok=false if the
|
||||
// library has none. Unlike a lookup it does not ask the metadata provider.
|
||||
func (c *Client) SeriesByTVDB(ctx context.Context, tvdbID int) (Series, bool, error) {
|
||||
var series []Series
|
||||
err := c.Do(ctx, http.MethodGet, "series", url.Values{"tvdbId": {strconv.Itoa(tvdbID)}}, nil, &series)
|
||||
for _, s := range series { // checked: a version that ignores the filter sends the whole library
|
||||
if s.TVDBID == tvdbID && s.InLibrary() {
|
||||
return s, true, err
|
||||
}
|
||||
}
|
||||
return Series{}, false, err
|
||||
}
|
||||
|
||||
// AddOptions are the choices from the add form.
|
||||
type AddOptions struct {
|
||||
QualityProfileID int
|
||||
|
||||
+6
-3
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user