Disk cache: version the poster folder
Posters stored by the previous build (the downloaded bytes, for real images Radarr's 333x500 copy) were reused as they were for 30 days. Posters now live in posters-v2/; older folders are deleted at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
of their cells before they are sent. Ghostty shows an image that is smaller than its place at its
|
of their cells before they are sent. Ghostty shows an image that is smaller than its place at its
|
||||||
own size, so library posters (Radarr's copy is only 333×500, not 500×750 as assumed in 0.4.0)
|
own size, so library posters (Radarr's copy is only 333×500, not 500×750 as assumed in 0.4.0)
|
||||||
came out smaller than the others. Library titles now also use the original poster for real
|
came out smaller than the others. Library titles now also use the original poster for real
|
||||||
images. The disk cache keeps posters at the size shown, not as downloaded.
|
images. The disk cache keeps posters at the size shown, not as downloaded
|
||||||
|
(in a new folder; posters stored by the previous way are deleted at startup).
|
||||||
- **Posters all the same size:** the details pane always shows a poster in a 2:3 box. Images
|
- **Posters all the same size:** the details pane always shows a poster in a 2:3 box. Images
|
||||||
slightly off that shape (some Radarr/Sonarr hand out) fill it, trimmed at the long sides; images
|
slightly off that shape (some Radarr/Sonarr hand out) fill it, trimmed at the long sides; images
|
||||||
far from it (square, landscape) are shown whole and centred. Before, such posters came out
|
far from it (square, landscape) are shown whole and centred. Before, such posters came out
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Notes:
|
|||||||
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
||||||
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
|
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
|
||||||
- **Downloading in the Library:** while the Library is visible it reads the Radarr and Sonarr queues (`/queue`, matched to titles by `movieId` / `seriesId`) when it opens, every 10 s, and 3 s after a grab. A title with queue entries gets a blue mark (filled when monitored, hollow when not) instead of the green or grey one, and its details a line "Downloading 45% · 0:06:12 left" (series: "Downloading N episodes"). A queue that cannot be read leaves the marks as they were. After a successful grab the release list closes (if nothing was opened on top of it).
|
- **Downloading in the Library:** while the Library is visible it reads the Radarr and Sonarr queues (`/queue`, matched to titles by `movieId` / `seriesId`) when it opens, every 10 s, and 3 s after a grab. A title with queue entries gets a blue mark (filled when monitored, hollow when not) instead of the green or grey one, and its details a line "Downloading 45% · 0:06:12 left" (series: "Downloading N episodes"). A queue that cannot be read leaves the marks as they were. After a successful grab the release list closes (if nothing was opened on top of it).
|
||||||
- **Disk cache:** posters and casts are kept between runs in `os.UserCacheDir()/arr-tui` (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS; directories `0700`, files written atomically). Posters are stored as decoded (at most 640 or 160 pixels tall, JPEG quality 90), one file per poster and size (small copy for block characters, large for real images) under `posters/<hash>.img`; a file is used for 30 days (by its modification time) and dropped when it cannot be decoded; at startup files older than that are deleted, then the oldest until the rest fit 200 MB. Casts are one `people.json` (key = `movie:tmdb:<id>` / `show:tvdb:<id>`, with the time it was fetched); entries are used for 7 days, at most 2000 are kept (the oldest go first), titles Trakt does not know are kept as having no cast; new casts are written 5 s after the last one and when the program quits. Changing the Trakt client ID keeps the casts and only forgets failed lookups.
|
- **Disk cache:** posters and casts are kept between runs in `os.UserCacheDir()/arr-tui` (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS; directories `0700`, files written atomically). Posters are stored as decoded (at most 640 or 160 pixels tall, JPEG quality 90), one file per poster and size (small copy for block characters, large for real images) under `posters-v2/<hash>.img` (the folder is versioned: folders of earlier storage formats are deleted at startup, so their files are not used as they are); a file is used for 30 days (by its modification time) and dropped when it cannot be decoded; at startup files older than that are deleted, then the oldest until the rest fit 200 MB. Casts are one `people.json` (key = `movie:tmdb:<id>` / `show:tvdb:<id>`, with the time it was fetched); entries are used for 7 days, at most 2000 are kept (the oldest go first), titles Trakt does not know are kept as having no cast; new casts are written 5 s after the last one and when the program quits. Changing the Trakt client ID keeps the casts and only forgets failed lookups.
|
||||||
- **Season posters:** the series drill-down reads the series with `includeSeasonImages=true` (Sonarr only sends season posters when asked, and keeps no copy of them: they are TheTVDB links, fetched without credentials). A selected season or episode shows its season's poster; seasons without one, or whose poster cannot be loaded, show the series poster.
|
- **Season posters:** the series drill-down reads the series with `includeSeasonImages=true` (Sonarr only sends season posters when asked, and keeps no copy of them: they are TheTVDB links, fetched without credentials). A selected season or episode shows its season's poster; seasons without one, or whose poster cannot be loaded, show the series poster.
|
||||||
- **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search/<type>/<id>?type=movie|show`), then `/movies|shows/<trakt id>/people` is read. Casts are cached, also on disk (see Disk cache; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes).
|
- **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search/<type>/<id>?type=movie|show`), then `/movies|shows/<trakt id>/people` is read. Casts are cached, also on disk (see Disk cache; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes).
|
||||||
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd, Trakt and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
|
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd, Trakt and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ const (
|
|||||||
posterCacheCap = 200 << 20 // bytes of posters kept on disk
|
posterCacheCap = 200 << 20 // bytes of posters kept on disk
|
||||||
peopleTTL = 7 * 24 * time.Hour // a cast on disk is used this long
|
peopleTTL = 7 * 24 * time.Hour // a cast on disk is used this long
|
||||||
maxPeopleSaved = 2000 // casts kept (in memory and on disk)
|
maxPeopleSaved = 2000 // casts kept (in memory and on disk)
|
||||||
|
|
||||||
|
// postersDir names the poster folder for the current way of storing them
|
||||||
|
// (v2: as decoded, at the size used). Folders of older ways are deleted by
|
||||||
|
// prunePosters: their files would otherwise be used as they are (v1 kept
|
||||||
|
// Radarr's 333×500 copies for real images).
|
||||||
|
postersDir = "posters-v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// oldPosterDirs are poster folders of earlier versions.
|
||||||
|
var oldPosterDirs = []string{"posters"}
|
||||||
|
|
||||||
// cacheDirFunc returns the directory for the disk cache, "" for none. It is a
|
// cacheDirFunc returns the directory for the disk cache, "" for none. It is a
|
||||||
// variable so tests can turn the cache off.
|
// variable so tests can turn the cache off.
|
||||||
var cacheDirFunc = func() string {
|
var cacheDirFunc = func() string {
|
||||||
@@ -42,7 +51,7 @@ func (d *diskCache) posterPath(key string, large bool) string {
|
|||||||
size = "large"
|
size = "large"
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256([]byte(size + "|" + key))
|
sum := sha256.Sum256([]byte(size + "|" + key))
|
||||||
return filepath.Join(d.dir, "posters", hex.EncodeToString(sum[:16])+".img")
|
return filepath.Join(d.dir, postersDir, hex.EncodeToString(sum[:16])+".img")
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPoster returns the stored bytes of a poster, if they are not too old.
|
// readPoster returns the stored bytes of a poster, if they are not too old.
|
||||||
@@ -85,9 +94,12 @@ func (d *diskCache) prunePosters() {
|
|||||||
size int64
|
size int64
|
||||||
mod time.Time
|
mod time.Time
|
||||||
}
|
}
|
||||||
|
for _, old := range oldPosterDirs {
|
||||||
|
_ = os.RemoveAll(filepath.Join(d.dir, old))
|
||||||
|
}
|
||||||
var files []file
|
var files []file
|
||||||
var total int64
|
var total int64
|
||||||
_ = filepath.WalkDir(filepath.Join(d.dir, "posters"), func(p string, e fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(filepath.Join(d.dir, postersDir), func(p string, e fs.DirEntry, err error) error {
|
||||||
if err != nil || e.IsDir() {
|
if err != nil || e.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,3 +105,19 @@ func TestPeopleCacheKeepsTheNewest(t *testing.T) {
|
|||||||
t.Error("new casts must be written")
|
t.Error("new casts must be written")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostersOfOlderVersionsAreDropped(t *testing.T) {
|
||||||
|
d := &diskCache{dir: t.TempDir()}
|
||||||
|
old := filepath.Join(d.dir, "posters", "abc.img")
|
||||||
|
if err := writeAtomic(old, []byte("v1")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d.writePoster("k", true, []byte("v2"))
|
||||||
|
d.prunePosters()
|
||||||
|
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||||
|
t.Error("a poster of the old format was kept")
|
||||||
|
}
|
||||||
|
if data, ok := d.readPoster("k", true); !ok || string(data) != "v2" {
|
||||||
|
t.Error("the current poster was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user