Series drill-down: mark downloading episodes and seasons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Downloading episodes in a series' seasons and episodes:** like the Library, the drill-down
|
||||
marks episodes in Sonarr's download queue with a blue dot and "downloading 45%" as their status
|
||||
(details: "Downloading 45% · 0:01:40 left"), and seasons with such episodes with a blue dot.
|
||||
When an episode has been imported, its status changes to downloaded by itself.
|
||||
- **Moving through lists is quick again with real-image posters:** scaling and encoding a poster
|
||||
for the terminal (about 200 ms since posters are sent at the size of their box) now happens in
|
||||
the background; the poster appears when it is ready, and the selection never waits for it.
|
||||
|
||||
@@ -13,7 +13,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
||||
and the US box office for movies. `[` and `]` switch lists; titles already in your library are
|
||||
ticked, the others can be added right there (Enter). Needs a free Trakt client ID (see Configure).
|
||||
- **Library**: movies and series with their status; the dot before a title turns blue while it
|
||||
is downloading, and the details say how far.
|
||||
is downloading, and the details say how far. In a series' seasons and episodes too.
|
||||
- **Cache**: posters (30 days, at most 200 MB), casts (7 days) and the Radarr/Sonarr lookups of
|
||||
Trakt list titles (3 days) are kept between runs in
|
||||
`~/.cache/arr-tui` (`$XDG_CACHE_HOME`; `~/Library/Caches/arr-tui` on macOS), so they show at
|
||||
|
||||
@@ -70,7 +70,7 @@ Notes:
|
||||
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
|
||||
- **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`).
|
||||
- **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. A series' seasons and episodes (the drill-down) read the Sonarr queue in the same way while open (when it opens and every 10 s, matched by `seriesId` and `episodeId`): an episode in the queue gets the blue mark, "downloading 45%" as its status (unless it already has a file, i.e. an upgrade) and the "Downloading" line in its details; a season with such episodes gets the blue mark and "Downloading N episode(s)". When an episode leaves the queue the episodes are loaded again, so its status follows. 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-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. Trakt list titles: whether a title is in the library is asked every time (`GET movie?tmdbId=` / `series?tvdbId=`, a quick query of the server's own database; the answer is checked against the id, as a version that ignores the filter sends the whole library); for titles that are not, the Radarr/Sonarr lookup (which asks the metadata provider, about a second each) is kept in `lookups.json` (key = `tmdb:<id>` / `tvdb:<id>`, the result as the server sent it, with the time it was fetched) for 3 days, at most 2000, written like the casts. The Trakt list itself is fetched again after 15 minutes as before.
|
||||
- **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).
|
||||
|
||||
@@ -145,7 +145,7 @@ def sonarr(path, q):
|
||||
return {"records": [
|
||||
{"id": 7, "title": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "status": "downloading", "trackedDownloadStatus": "ok",
|
||||
"trackedDownloadState": "downloading", "size": 2.1 * GB, "sizeleft": 1.3 * GB, "timeleft": "00:01:40", "downloadClient": "SABnzbd",
|
||||
"downloadId": "SABnzbd_nzo_a2", "protocol": "usenet", "seriesId": 1, "series": {"title": "Signal Fire"},
|
||||
"downloadId": "SABnzbd_nzo_a2", "protocol": "usenet", "seriesId": 1, "episodeId": 1021, "series": {"title": "Signal Fire"},
|
||||
"episode": {"seasonNumber": 3, "episodeNumber": 5, "title": "Ember"}}]}
|
||||
if path.endswith("/calendar"):
|
||||
out = []
|
||||
|
||||
@@ -31,8 +31,9 @@ type QueueItem struct {
|
||||
DownloadID string `json:"downloadId"` // the download client's id, e.g. SABnzbd's nzo_id
|
||||
Indexer string `json:"indexer"`
|
||||
Protocol string `json:"protocol"`
|
||||
MovieID int `json:"movieId,omitempty"` // Radarr
|
||||
SeriesID int `json:"seriesId,omitempty"` // Sonarr
|
||||
MovieID int `json:"movieId,omitempty"` // Radarr
|
||||
SeriesID int `json:"seriesId,omitempty"` // Sonarr
|
||||
EpisodeID int `json:"episodeId,omitempty"` // Sonarr; one item per episode
|
||||
|
||||
// Present depending on the include* parameters of the request.
|
||||
Movie *QueueMovie `json:"movie,omitempty"`
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestSeriesRowDetail(t *testing.T) {
|
||||
s := sonarr.Series{Title: "Andor", Year: 2022, Network: "Disney+", Status: "ended", Overview: "Series plot"}
|
||||
|
||||
// A season: the summary and the series plot.
|
||||
d := seriesRowDetail(s, nil, 2, seasonSummary{number: 2, total: 12, have: 5}, true, "2026-01-01")
|
||||
d := seriesRowDetail(s, nil, 2, seasonSummary{number: 2, total: 12, have: 5}, true, "2026-01-01", dlInfo{})
|
||||
for _, want := range []string{"Andor", "(2022)", "Disney+", "Season 2", "5 of 12 episodes", "Monitored: yes"} {
|
||||
if !strings.Contains(d.head, want) {
|
||||
t.Errorf("season head missing %q:\n%s", want, d.head)
|
||||
@@ -141,7 +141,7 @@ func TestSeriesRowDetail(t *testing.T) {
|
||||
|
||||
// An episode: its own title, air date, status and plot.
|
||||
ep := &sonarr.Episode{SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome [Home]", AirDate: "2025-05-01", HasFile: true, Monitored: true, Overview: "Episode plot"}
|
||||
d = seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01")
|
||||
d = seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01", dlInfo{})
|
||||
for _, want := range []string{"S02E05 Welcome [Home[]", "Aired: 2025-05-01", "downloaded"} {
|
||||
if !strings.Contains(d.head, want) {
|
||||
t.Errorf("episode head missing %q:\n%s", want, d.head)
|
||||
@@ -152,7 +152,7 @@ func TestSeriesRowDetail(t *testing.T) {
|
||||
}
|
||||
// Without an episode plot the series plot stays.
|
||||
ep.Overview = ""
|
||||
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01"); d.plot != "Series plot" {
|
||||
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01", dlInfo{}); d.plot != "Series plot" {
|
||||
t.Errorf("fallback plot = %q", d.plot)
|
||||
}
|
||||
}
|
||||
|
||||
+82
-12
@@ -44,16 +44,22 @@ type seriesView struct {
|
||||
expanded map[int]bool
|
||||
rows []epRow
|
||||
loaded bool
|
||||
|
||||
// Episodes in Sonarr's download queue, by episode id; watched while the
|
||||
// view is open.
|
||||
downloading map[int]dlInfo
|
||||
queueStop func()
|
||||
}
|
||||
|
||||
// openSeries opens the season/episode drill-down above the current view.
|
||||
func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
||||
v := &seriesView{
|
||||
app: a, c: c, series: s, onChange: onChange,
|
||||
header: tview.NewTextView().SetDynamicColors(true),
|
||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
||||
detail: newDetailPane(a),
|
||||
expanded: map[int]bool{},
|
||||
header: tview.NewTextView().SetDynamicColors(true),
|
||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
||||
detail: newDetailPane(a),
|
||||
expanded: map[int]bool{},
|
||||
downloading: map[int]dlInfo{},
|
||||
}
|
||||
v.table.SetBorder(true)
|
||||
v.cover = coverFor(c.Client, s.Images, true)
|
||||
@@ -90,8 +96,54 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
||||
|
||||
v.render()
|
||||
// The pane goes away with the view; stop restyling it then.
|
||||
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", func() { a.forgetPane(v.detail) })
|
||||
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", func() {
|
||||
a.forgetPane(v.detail)
|
||||
v.queueStop()
|
||||
})
|
||||
v.reload()
|
||||
v.loadQueue()
|
||||
v.queueStop = a.every(libQueueInterval, v.loadQueue)
|
||||
}
|
||||
|
||||
// loadQueue marks the episodes in Sonarr's download queue. When one has left
|
||||
// it (imported, or removed) the episodes are loaded again, so its status
|
||||
// follows. Failures leave the marks as they were.
|
||||
func (v *seriesView) loadQueue() {
|
||||
id := v.series.ID
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
items, err := v.c.Queue(ctx)
|
||||
v.app.post(func() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := queueByEpisode(items, id)
|
||||
left := false
|
||||
for ep := range v.downloading {
|
||||
if _, still := now[ep]; !still {
|
||||
left = true
|
||||
}
|
||||
}
|
||||
v.downloading = now
|
||||
if left {
|
||||
v.reload()
|
||||
} else {
|
||||
v.render()
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// seasonDownloads counts the episodes of a season in the download queue.
|
||||
func (v *seriesView) seasonDownloads(season int) int {
|
||||
n := 0
|
||||
for _, e := range v.episodes {
|
||||
if _, ok := v.downloading[e.ID]; ok && e.SeasonNumber == season {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// reload fetches the series and its episodes.
|
||||
@@ -192,18 +244,23 @@ func (v *seriesView) render() {
|
||||
} else {
|
||||
status = "[gray]" + status
|
||||
}
|
||||
v.table.SetCell(row, 0, tview.NewTableCell(monMark(v.seasonMonitored(r.season))))
|
||||
v.table.SetCell(row, 0, tview.NewTableCell(libMark(v.seasonMonitored(r.season), v.seasonDownloads(r.season) > 0)))
|
||||
v.table.SetCell(row, 1, tview.NewTableCell(arrow))
|
||||
v.table.SetCell(row, 2, tview.NewTableCell("[::b]"+seasonLabel(r.season)).SetExpansion(1))
|
||||
v.table.SetCell(row, 3, tview.NewTableCell(""))
|
||||
v.table.SetCell(row, 4, tview.NewTableCell(status))
|
||||
} else {
|
||||
e := r.ep
|
||||
v.table.SetCell(row, 0, tview.NewTableCell(monMark(e.Monitored)))
|
||||
dl, downloading := v.downloading[e.ID]
|
||||
status := episodeStatus(*e, today)
|
||||
if downloading && !e.HasFile {
|
||||
status = fmt.Sprintf("[blue]downloading %.0f%%", dl.progress)
|
||||
}
|
||||
v.table.SetCell(row, 0, tview.NewTableCell(libMark(e.Monitored, downloading)))
|
||||
v.table.SetCell(row, 1, tview.NewTableCell(fmt.Sprintf("E%02d", e.EpisodeNumber)))
|
||||
v.table.SetCell(row, 2, tview.NewTableCell(tview.Escape(e.Title)).SetExpansion(1))
|
||||
v.table.SetCell(row, 3, tview.NewTableCell(e.AirDate))
|
||||
v.table.SetCell(row, 4, tview.NewTableCell(episodeStatus(*e, today)))
|
||||
v.table.SetCell(row, 4, tview.NewTableCell(status))
|
||||
}
|
||||
if hasSel && r.key() == selKey {
|
||||
selRow = row
|
||||
@@ -248,7 +305,7 @@ func episodeStatus(e sonarr.Episode, today string) string {
|
||||
func (v *seriesView) renderDetail() {
|
||||
r, ok := v.selected()
|
||||
if !ok {
|
||||
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, ""), v.cover)
|
||||
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, "", dlInfo{}), v.cover)
|
||||
return
|
||||
}
|
||||
var sum seasonSummary
|
||||
@@ -257,7 +314,11 @@ func (v *seriesView) renderDetail() {
|
||||
sum = se
|
||||
}
|
||||
}
|
||||
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02")), v.seasonCover(r.season))
|
||||
dl := dlInfo{count: v.seasonDownloads(r.season)}
|
||||
if r.ep != nil {
|
||||
dl = v.downloading[r.ep.ID]
|
||||
}
|
||||
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02"), dl), v.seasonCover(r.season))
|
||||
}
|
||||
|
||||
// seasonCover is the poster of a season, for its row and its episodes: the
|
||||
@@ -287,8 +348,9 @@ func (v *seriesView) seasonCover(season int) coverRef {
|
||||
|
||||
// seriesRowDetail is the detail text of the drill-down: the series, then the
|
||||
// selected season (ep == nil) or episode. The plot is the episode's, or the
|
||||
// series' while a season is selected.
|
||||
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string) detailText {
|
||||
// series' while a season is selected. dl is the episode's place in the
|
||||
// download queue, or for a season the number of its episodes there.
|
||||
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string, dl dlInfo) detailText {
|
||||
head := "[::b]" + tview.Escape(s.Title) + "[::-]"
|
||||
if s.Year > 0 {
|
||||
head += fmt.Sprintf(" (%d)", s.Year)
|
||||
@@ -306,6 +368,11 @@ func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum season
|
||||
case ep == nil:
|
||||
head += "[::b]" + seasonLabel(season) + "[::-]\n"
|
||||
head += fmt.Sprintf("%d of %d episodes downloaded\n", sum.have, sum.total)
|
||||
if dl.count == 1 {
|
||||
head += "[blue]Downloading[-] 1 episode\n"
|
||||
} else if dl.count > 1 {
|
||||
head += dl.dlLine()
|
||||
}
|
||||
head += monitoredLine(seasonMonitored)
|
||||
head += "[gray]Enter expands · t monitors the whole season · i searches for releases[-]\n"
|
||||
default:
|
||||
@@ -314,6 +381,9 @@ func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum season
|
||||
head += "Aired: " + ep.AirDate + "\n"
|
||||
}
|
||||
head += "Status: " + episodeStatus(*ep, today) + "[-]\n"
|
||||
if dl.count > 0 {
|
||||
head += dl.dlLine()
|
||||
}
|
||||
head += monitoredLine(ep.Monitored)
|
||||
if ep.Overview != "" {
|
||||
plot = tview.Escape(ep.Overview)
|
||||
|
||||
+37
-19
@@ -8,8 +8,8 @@ import (
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
)
|
||||
|
||||
// libQueueInterval is how often the Library asks for the download queues
|
||||
// while it is visible.
|
||||
// libQueueInterval is how often the Library (and a series' seasons and
|
||||
// episodes) ask for the download queues while they are visible.
|
||||
const libQueueInterval = 10 * time.Second
|
||||
|
||||
// dlInfo says that a library title is in the Radarr/Sonarr download queue.
|
||||
@@ -52,6 +52,38 @@ func queueByTitle(items []arr.QueueItem, series bool) map[int]dlInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
// queueByEpisode picks the queue entries of one series, by episode.
|
||||
func queueByEpisode(items []arr.QueueItem, seriesID int) map[int]dlInfo {
|
||||
out := map[int]dlInfo{}
|
||||
for _, q := range items {
|
||||
if q.SeriesID != seriesID || q.EpisodeID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, seen := out[q.EpisodeID]; !seen {
|
||||
out[q.EpisodeID] = dlInfo{count: 1, progress: q.Progress(), timeLeft: q.TimeLeft}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// every runs f on the UI goroutine every d until stop is called.
|
||||
func (a *App) every(d time.Duration, f func()) (stop func()) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
a.post(f)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// loadQueue fetches both download queues and marks the titles in them.
|
||||
// Failures (a service down or not configured) leave the marks as they were.
|
||||
func (v *libraryView) loadQueue() {
|
||||
@@ -77,29 +109,15 @@ func (v *libraryView) loadQueue() {
|
||||
// startQueueWatch refreshes the queue marks while the Library is visible.
|
||||
func (v *libraryView) startQueueWatch() {
|
||||
v.loadQueue()
|
||||
if v.queueStop != nil {
|
||||
return
|
||||
if v.queueStop == nil {
|
||||
v.queueStop = v.app.every(libQueueInterval, v.loadQueue)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
v.queueStop = stop
|
||||
go func() {
|
||||
t := time.NewTicker(libQueueInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
v.app.post(v.loadQueue)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// deactivate stops the queue watch when the Library is left.
|
||||
func (v *libraryView) deactivate() {
|
||||
if v.queueStop != nil {
|
||||
close(v.queueStop)
|
||||
v.queueStop()
|
||||
v.queueStop = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ type libraryView struct {
|
||||
|
||||
// Titles in the Radarr/Sonarr download queue, by kind and id (libqueue.go).
|
||||
downloading [2]map[int]dlInfo
|
||||
queueStop chan struct{}
|
||||
queueStop func()
|
||||
}
|
||||
|
||||
type seriesFiles struct {
|
||||
|
||||
@@ -381,3 +381,31 @@ func TestDownloadingTitlesAreMarked(t *testing.T) {
|
||||
t.Error("an unmonitored title in the queue keeps the hollow mark")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodesInTheQueue(t *testing.T) {
|
||||
got := queueByEpisode([]arr.QueueItem{
|
||||
{SeriesID: 7, EpisodeID: 70, Size: 10, SizeLeft: 5, TimeLeft: "00:01:00"},
|
||||
{SeriesID: 7, EpisodeID: 71},
|
||||
{SeriesID: 8, EpisodeID: 80}, // another series
|
||||
{SeriesID: 7}, // no episode
|
||||
}, 7)
|
||||
if len(got) != 2 || got[70].progress != 50 || got[70].timeLeft != "00:01:00" {
|
||||
t.Fatalf("queueByEpisode: %+v", got)
|
||||
}
|
||||
|
||||
s := sonarr.Series{Title: "Signal Fire"}
|
||||
ep := &sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, Title: "Ember", Monitored: true}
|
||||
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", got[70]); !strings.Contains(d.head, "Downloading[-] 50% · 00:01:00 left") {
|
||||
t.Errorf("episode: %q", d.head)
|
||||
}
|
||||
sum := seasonSummary{number: 3, total: 10, have: 4}
|
||||
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 1}); !strings.Contains(d.head, "Downloading[-] 1 episode\n") {
|
||||
t.Errorf("season with one: %q", d.head)
|
||||
}
|
||||
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 2}); !strings.Contains(d.head, "Downloading[-] 2 episodes") {
|
||||
t.Errorf("season with two: %q", d.head)
|
||||
}
|
||||
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", dlInfo{}); strings.Contains(d.head, "Downloading") {
|
||||
t.Errorf("not downloading: %q", d.head)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user